当前位置: 首页 > news >正文

JUC并发编程04 - 同步/syn-ed(01)

依旧参考笔记:JavaNote/Prog.md at main · Seazean/JavaNote

同步

临界区

让我们先来预设一个场景:银行只有一个柜员窗口(单服务窗口),假设你和朋友都知道卡里有 10000元,你们俩在不同地方,同时用手机银行发起一笔 5000元 的转账。银行后台系统怎么处理这笔钱?我们来看代码。

 1. 临界资源:银行卡余额

临界资源:一次仅允许一个进程使用的资源成为临界资源。

临界资源是共享资源,多个线程(你和朋友的操作)都要读和写它。

临界资源:一次只能被一个线程操作的资源 → 就像一个柜员窗口只能服务一个人

2. 临界区:操作余额的代码块

public class BankAccount {private int balance = 10000;// 这个方法里的代码就是“临界区”public void transfer(int amount) {if (balance >= amount) {              // 1. 判断余额是否足够System.out.println("准备转账 " + amount + "元");try {Thread.sleep(1000);           // 模拟处理时间(比如打印凭条)} catch (InterruptedException e) {e.printStackTrace();}balance -= amount;                // 2. 扣钱System.out.println("转账成功,余额:" + balance);} else {System.out.println("余额不足!");}}public static void main(String[] args) {BankAccount account = new BankAccount();// 你和朋友同时发起转账(两个线程)new Thread(() -> account.transfer(5000)).start();new Thread(() -> account.transfer(5000)).start();}
}

临界区:就是 transfer() 方法里操作 balance 的这段代码。多个线程进来,就可能出问题。

代码输出结果如下:

3. 竞态条件:顺序乱了,结果错了

这个运行结果有什么问题呢?问题在于两个线程都通过了 if (balance >= amount) 判断(因为当时余额还是10000)。然后都执行了 balance -= amount。结果是:总共取了10000,但系统没有阻止第二次操作,导致逻辑错误!

这就是竞态条件:多个线程进入临界区,执行顺序不确定,导致最终结果不可预测或错误。

4. 解决方案:加 synchronized(相当于“叫号排队”)

银行柜员说:“一次只能服务一个人,其他人等叫号!”我们给方法加 synchronized

package com.cg.jucproject.demo;public class BankAccount {private int balance = 10000;// 这个方法里的代码就是“临界区”public synchronized void transfer(int amount) {if (balance >= amount) {System.out.println("准备转账 " + amount + "元");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}balance -= amount;System.out.println("转账成功,余额:" + balance);} else {System.out.println("余额不足!");}}public static void main(String[] args) {BankAccount account = new BankAccount();// 你和朋友同时发起转账(两个线程)new Thread(() -> account.transfer(5000)).start();new Thread(() -> account.transfer(6000)).start();}
}

synchronized 相当于:柜员窗口前加了个“叫号机”。你来了,拿到号,轮到你时才能进去办理。朋友来了,发现有人在办,就在大厅等着(阻塞)。办完出来,系统自动叫下一个。此时的代码输出就是正确的:

你先办完,余额变成5000;朋友进来时,余额不够,被拒绝。避免了竞态条件,保证了线程安全

5. synchronized 原理:对象锁 + 管程

你可以把 synchronized 想象成:

  • 每个对象(比如 account)都有一个“隐形的锁”。
  • 线程要执行 synchronized 方法,必须先拿到这个锁。
  • 拿到了 → 进去办事(执行代码)。
  • 拿不到 → 在外面等(阻塞)。
  • 办完事 → 自动还锁,下一个线程进来。

这就是 管程(Monitor) 的机制:由 JVM 实现,保证同一时刻只有一个线程能进入临界区。

 6. 互斥与同步

概念解释例子
互斥防止多个线程同时进入临界区柜员窗口一次只服务一人
同步控制线程执行顺序,一个等另一个孩子等妈妈发工资后才能拿到生活费

再举一个同步的demo:

// 同步例子:孩子等妈妈发工资
volatile boolean salaryArrived = false;Thread mom = new Thread(() -> {System.out.println("妈妈去上班...");try { Thread.sleep(5000); } catch (InterruptedException e) {}salaryArrived = true;System.out.println("工资到账!");
});Thread child = new Thread(() -> {while (!salaryArrived) {System.out.println("孩子:工资到了吗?");try { Thread.sleep(500); } catch (InterruptedException e) {}}System.out.println("孩子:我可以取钱了!");
});

孩子线程在“等”妈妈线程完成,这就是同步

7. 性能比较

类型举例特点
线程不安全(快)ArrayListHashMap多线程用会出错
线程安全(慢)VectorHashtablesynchronizedList加锁,安全但慢

建议:如果确定是单线程环境,用“快但不安全”的类;如果是多线程共享数据,一定要用“安全”的方式。

8. 非阻塞方案:原子变量

不想排队等?可以用“自动取款机”——原子操作

import java.util.concurrent.atomic.AtomicInteger;public class SafeCounter {private AtomicInteger count = new AtomicInteger(0);public void increment() {count.incrementAndGet(); // 原子操作,不加锁也能保证安全}
}

syn-ed

使用锁

同步块

加锁的方式:同步代码块

锁对象:理论上可以是任意的唯一对象

这句话说的是 Java 里 synchronized 锁的“锁”到底是个啥。

想象一下门上有个锁,你一锁,别人就进不来了。在 Java 里,这个“锁”不是实物,而是一个对象。你用哪个对象来当锁,哪个对象就起到“门锁”的作用。

这个“锁对象”可以是任何一个对象,比如:

  • 一个 Object obj = new Object();
  • 或者 String lock = "abc";
  • 甚至是你自己创建的一个类的实例。

但关键是:多个线程必须用同一个对象来上锁,才能起作用。就像多个门,你锁了一个,别人还能进另一个,那就没用。所以说“唯一对象”意思是:大家都要用同一个对象当锁,才能互斥。

举一个例子。因为两个线程都用的是 lock 这个对象,所以它们是互斥的——一个进去,另一个就得等。但如果线程2用的是 new Object(),那就不是同一个锁,就没用!

所以总结:锁可以是任何对象,但必须是同一个对象,才能管住大家。

Object lock = new Object();// 线程1
synchronized(lock) {// 干活
}// 线程2
synchronized(lock) {// 干活
}

synchronized 是可重入、不公平的重量级锁

(1)可重入

意思是:同一个线程,可以多次进入同一个锁。

举个例子:

synchronized(lock) {// 第一次进入doSomething();  // 它里面又调用了另一个 synchronized(lock) 的方法
}

如果锁是“不可重入”的,那这个线程在 doSomething() 里又要拿锁,就会把自己卡住(因为锁已经被自己拿着了),这就死锁了。但 synchronized可重入的,它知道:“哦,是你自己又来了”,所以允许你再次进入。就像你拿着家门钥匙进了门,然后你去卧室又开个柜子,不需要再拿一次钥匙,因为你已经是主人了。

(2)不公平

  • “公平”意思是:谁排队第一个,谁先拿到锁。
  • “不公平”意思是:不保证顺序,有时候后来的线程反而先抢到锁。

举个例子:有3个线程在排队等锁:线程A、B、C(A最早等,C最晚)。当前持有锁的线程释放了。公平锁:A 一定先拿到。不公平锁:可能 A 拿到,也可能 B 或 C 突然抢到了(比如它们刚好在CPU上跑得快)。

synchronized不公平的,它不排队,谁抢得快谁上。好处是效率高,坏处是可能有人“饿死”(一直抢不到)。

(3)重量级锁

早期的 synchronized 是基于操作系统底层的互斥量实现的,每次加锁/解锁都要跟操作系统打交道。这个过程开销大,就像“杀鸡用牛刀”,所以叫“重量级”。但是从 JDK 1.6 开始,Java 对 synchronized 做了很多优化,比如:偏向锁,轻量级锁,自旋锁。所以现在 synchronized 其实已经没那么“重”了,性能很好。但“重量级锁”是它历史上的一个标签,用来形容它早期的实现方式。

原则上:

  • 锁对象建议使用共享资源
  • 在实例方法中使用 this 作为锁对象,锁住的 this 正好是共享资源
  • 在静态方法中使用类名 .class 字节码作为锁对象,因为静态成员属于类,被所有实例对象共享,所以需要锁住类

为啥要加锁?因为多个线程在“抢”同一个东西(比如一个变量、一个列表、一个文件),怕他们同时改,改乱了。所以得加个“锁”,谁拿到锁,谁才能进去改,其他人等着。那“锁对象”就是这个“锁”本身——它是个“通行证”,只有拿着它的人才能干活。

原则上:锁对象建议使用“共享资源”。这句话的意思是:你锁的东西,应该是大家真正要抢的那个东西,不能锁错对象。举个生活例子:你们公司只有一个打印机(共享资源),大家都要打印。如果你不锁“打印机”,而是锁“自己的水杯”,那根本没用——别人照样能去打印,乱成一锅粥。

在编程里也一样:如果多个线程要改一个 List,那你就应该用这个 List 当锁;如果他们要改某个用户数据,那就用这个用户对象当锁。

总结:锁的对象,得是“大家抢的那个东西”,不然就白锁了。

在实例方法中用 this 当锁。啥是“实例方法”?就是普通的方法,比如:

public class BankAccount {private int money = 0;public void withdraw(int amount) {// 取钱,改 money}
}

这里,money 是每个账户自己的钱,是“实例变量”——张三的账户和李四的账户,各自的 money 是独立的。所以当张三取钱时,我们要防的是:多个线程同时操作张三这个账户。这时候,用 this 当锁就很合适:

public synchronized void withdraw(int amount) {// 或者 synchronized(this)money -= amount;
}

因为 this 就是当前这个账户对象(比如张三的账户),它既是锁,也是共享资源本身。多个线程来操作张三账户,都得先拿到 this 锁,这样就不会乱。所以说:this 当锁,正好锁住了真正被共享的资源(当前对象)

在静态方法中用 类名.class 当锁。静态方法和静态变量是属于“整个类”的,不是某个对象的。比如:

public class Counter {private static int count = 0;  // 所有实例共享public static synchronized void increment() {count++;}
}

这里 count 是静态变量,所有 Counter 对象都共用这一个 count。所以,不管你是用 new Counter() 创建了多少个对象,它们改的都是同一个 count。这时候,你不能用 this,因为:

  • 每个对象的 this 都不一样。
  • 你锁了张三的 this,李四还能用他的 this 去改 count,还是乱。

那怎么办?得锁一个大家共同依赖的东西——就是这个类本身。

Counter.class 是唯一的!不管创建多少对象,Counter.class 只有一个。所以得这么改:

synchronized(Counter.class) {count++;
}

这样,所有线程,不管操作哪个对象,都得先拿到 Counter.class 这把锁,才能改 count。所以说:静态成员被所有实例共享,就得锁“类”,用 .class 最合适

同步代码块格式:

synchronized(锁对象){// 访问共享资源的核心代码
}

写一个demo代码,这段代码就是在探索怎么使用锁将一个数在线程安全的情况下变为0:

public class demo {// 共享的计数器// counter 是一个静态变量,属于类,所有线程都共享它。static int counter = 0;// static修饰,则元素是属于类本身的,不属于对象,与类一起加载一次,只有一个// 定义了一个“锁对象”,名字叫 room(房间),可以理解成“公共钥匙”static final Object room = new Object();public static void main(String[] args) throws InterruptedException {// 线程 t1:负责加 5000 次Thread t1 = new Thread(() -> {for (int i = 0; i < 5000; i++) {// ounter++ 被 synchronized(room) 包住了// 想执行 counter++?先拿到 room 这把钥匙!如果别人拿着,你就得等synchronized (room) {counter++;}}}, "t1");// 线程 t2:负责减 5000 次// t2 也循环 5000 次,每次减 1// counter-- 也被 synchronized(room) 保护着// 它也要先拿到 room 才能进去改Thread t2 = new Thread(() -> {for (int i = 0; i < 5000; i++) {synchronized (room) {counter--;}}}, "t2");// 让两个线程同时开始跑// 注意:是“同时”开始,不是先后!// 所以如果不加锁,它们会一起抢 counter,出问题!t1.start();t2.start();// join() 意思是:“主线程你先别走// 等 t1 和 t2 干完活再继续”// 这样才能保证:打印 counter 的时候// 两个线程都已经算完了t1.join();t2.join();System.out.println(counter);}
}

为什么要加锁(synchronized(room))?是因为:

  • t1 和 t2 都要用 room 当钥匙
  • 只有一把钥匙,所以同一时间只有一个线程能进入 synchronized 块
  • 一个在改 counter 时,另一个必须等
  • 这样就避免了“同时读写”的问题

最终结果大概率是:0(注意:因为 counter++counter-- 次数相等,且都被保护,所以最终是 0)最后输出一定是0。

为什么用 room 当锁对象?是因为;

  • 是 static 的,所有线程都能看到同一个
  • 是唯一的,能起到“互斥”的作用
  • 是一个专门用来当锁的对象,清晰明了

不能用 this,因为:

  • this 是当前对象,但在 static 方法里没有 this
  • 而且 main 是静态上下文,不能用实例对象

也不能随便 new 一个对象当锁,比如:

synchronized(new Object()) { ... }

那每个线程都拿自己的新对象当锁,钥匙都不一样,等于没锁。

假设去掉了锁,就是去掉 synchronized(room),这行代码其实不是原子操作,它分三步:

  1. 读 counter 的值
  2. +1
  3. 写回去

两个线程可能同时读到同一个值,比如都读到 100,然后都 +1,都写成 101 —— 实际只加了一次,但应该加两次!这就叫“线程安全问题”,结果可能不是 0,而是比如 -100、+200 等乱七八糟的数。

同步方法

把出现线程安全问题的核心方法锁起来,每次只能一个线程进入访问

什么是线程安全问题?想象一下:你和你朋友一起去银行取同一个账户的钱。你这边刚查完余额是1000元,准备取500元;同时,你朋友也查了余额是1000元,也准备取500元。如果系统没做控制,你们俩同时操作,可能都成功取了500元,结果账户变成负的了!这就不对了。这就是线程安全问题——多个线程(比如你和你朋友)同时操作同一个资源(账户),导致数据出错。

synchronized 修饰的方法的不具备继承性,所以子类是线程不安全的,如果子类的方法也被 synchronized 修饰,两个锁对象其实是一把锁,而且是子类对象作为锁

同步方法是干啥的?为了解决上面的问题,Java 提供了一个叫 synchronized 的关键字,它的作用就是:给方法上个“锁”。就像银行柜台一次只能服务一个人,其他人得排队等。

同步方法:就是用 synchronized 修饰的方法,保证同一时间,只有一个线程能进来执行这个方法。

用法:直接给方法加上一个修饰符 synchronized

//同步方法
修饰符 synchronized 返回值类型 方法名(方法参数) { 方法体;
}
//同步静态方法
修饰符 static synchronized 返回值类型 方法名(方法参数) { 方法体;
}

举一个例子:

// 同步方法
public synchronized void 取钱(int 金额) {// 这个方法一次只能被一个线程调用// 锁的是当前对象(this)
}// 同步静态方法
public static synchronized void 打印时间() {// 这个方法一次也只能被一个线程调用// 锁的是整个类
}

同步方法底层也是有锁对象的:

如果方法是实例方法:同步方法默认用 this 作为的锁对象

public void 取钱(int 金额) {synchronized(this) {  // 锁住当前对象// 方法体}
}

如果方法是静态方法:同步方法默认用类名 .class 作为的锁对象

public static void 打印时间() {synchronized(账户类.class) {  // 锁住类的字节码// 方法体}
}

线程八锁

线程八锁就是考察 synchronized 锁住的是哪个对象

说明:主要关注锁住的对象是不是同一个

“线程八锁”不是 Java 的官方术语,也不是说有8种锁。它其实是程序员圈里一个经典的学习套路,用来考察你对 synchronized 的理解到底深不深。核心问题就一个:这个 synchronized 到底锁的是谁?因为只有锁的是同一个东西,多个线程才会互相等、排队; 如果锁的不是同一个东西,那大家各干各的,就不安全了

  • 锁住类对象,所有类的实例的方法都是安全的,类的所有实例都相当于同一把锁
  • 锁住 this 对象,只有在当前实例对象的线程内是安全的,如果有多个实例就不安全
方法类型锁的是谁?白话解释
普通方法加 synchronizedthis(当前对象)谁调用,就锁谁这个“人”
静态方法加 synchronized类.class(整个类的字节码)锁的是“这个类的所有对象”,相当于全局锁

就像你家大门:his 锁 → 像你家的防盗门,每户都有自己的门。类.class 锁 → 像小区的总大门,所有人进出都得过这道关。

那么举一个线程不安全的例子:

class Number {public static synchronized void a() {Thread.sleep(1000);System.out.println("1");}public synchronized void b() {System.out.println("2");}
}public static void main(String[] args) {Number n1 = new Number();Number n2 = new Number();new Thread(() -> { n1.a(); }).start();  // 线程1:调a()new Thread(() -> { n2.b(); }).start();  // 线程2:调b()
}

  • a() 是 静态同步方法 → 锁的是 Number.class(整个类)
  • b() 是 普通同步方法 → 锁的是 this,也就是具体对象(比如 n2)

现在:

  • 线程1 调 n1.a() → 锁的是 
  • 线程2 调 n2.b() → 锁的是 n2 这个对象

 锁的根本不是同一个东西!

  • 一个锁“小区大门”,一个锁“自己家门”
  • 所以它们可以同时执行,不会互相等

输出可能是:先打印 2,再打印 1
这叫线程不安全(因为没起到互斥作用)

再来举一个线程安全的例子:

class Number {public static synchronized void a() {Thread.sleep(1000);System.out.println("1");}public static synchronized void b() {System.out.println("2");}
}public static void main(String[] args) {Number n1 = new Number();Number n2 = new Number();new Thread(() -> { n1.a(); }).start();  // 线程1new Thread(() -> { n2.b(); }).start();  // 线程2
}
  • a() 和 b() 都是 静态同步方法
  • 所以它们锁的都是 Number.class

现在:

  • 线程1 调 n1.a() → 锁类
  • 线程2 调 n2.b() → 也锁类

锁的是同一个东西(类),所以必须排队。线程1 先执行,睡1秒,打印1;等它结束,线程2 才能进。输出一定是:先 1,后 2(或反过来,但不会同时)这就叫线程安全。

锁原理

Monitor

Monitor 可以翻译成“监视器”或“管程”,它是一个用来控制多个线程对共享资源访问的工具。简单来说,就是给对象加锁的一种机制。

每个 Java 对象都可以关联一个 Monitor 对象,Monitor 也是 class,其实例存储在堆中,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针,这就是重量级锁

  • 每个 Java 对象都可以关联一个 Monitor 对象:就像每个房间都有自己的门锁一样。
  • Monitor 存储在堆中:这个“门锁”是实实在在存在的东西,放在内存的堆区里。

什么是重量级锁?当你使用 synchronized 关键字给对象上锁时,如果进入了重量级锁状态,那么该对象头的 Mark Word 中就会被设置指向 Monitor 对象的指针

重量级锁 就是说这个锁比较“重”,需要更多的系统资源来维护,性能相对较低。

Mark Word 是 Java 对象头的一部分,它存储了一些元数据信息,比如哈希码、GC 分代年龄等。其中最重要的是锁标志位,用来表示当前对象的锁状态。32 位的 Mark Word:总共 32 个比特位(bit),分成几部分来存储不同的信息。

Mark Word 结构:最后两位是锁标志位

来解读一下这张图:

1. Normal 状态

  • hashcode:25:25 位用于存储哈希码。
  • age:4:4 位用于记录 GC 年龄。
  • biased_lock:0:偏向锁标志位为 0。
  • 01:最后两位是锁标志位,表示当前是 Normal 状态。

Normal 状态:最初始的状态,没有加锁。

2. Biased 状态

  • thread:23:23 位用于存储偏向线程的 ID。
  • epoch:2:2 位用于记录偏向时间戳。
  • age:4:4 位用于记录 GC 年龄。
  • biased_lock:1:偏向锁标志位为 1。
  • 01:最后两位是锁标志位,表示当前是 Biased 状态。

Biased 状态:偏向锁,偏向某个特定线程,减少锁的竞争。

3. Lightweight Locked 状态

  • ptr_to_lock_record:30:30 位用于存储指向轻量级锁记录的指针。
  • 00:最后两位是锁标志位,表示当前是 Lightweight Locked 状态。

Lightweight Locked 状态:轻量级锁,使用自旋锁机制,性能较好。

4. Heavyweight Locked 状态

  • ptr_to_heavyweight_monitor:30:30 位用于存储指向重量级 Monitor 对象的指针。
  • 10:最后两位是锁标志位,表示当前是 Heavyweight Locked 状态。

Heavyweight Locked 状态:重量级锁,性能较差但适用范围广。

5. Marked for GC 状态

  • 11:最后两位是锁标志位,表示当前对象被标记为垃圾回收。

Marked for GC 状态:对象即将被垃圾回收器回收。

64 位虚拟机 Mark Word:

Monitor工作流程

  • 开始时 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner,obj 对象的 Mark Word 指向 Monitor,把对象原有的 MarkWord 存入线程栈中的锁记录中(轻量级锁部分详解)

  • 在 Thread-2 上锁的过程,Thread-3、Thread-4、Thread-5 也执行 synchronized(obj),就会进入 EntryList BLOCKED(双向链表)
  • Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord
  • 唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞
  • WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制)

注意:

  • synchronized 必须是进入同一个对象的 Monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

http://www.dtcms.com/a/336291.html

相关文章:

  • prototype 和 _ _ proto _ _的关联
  • 计算机网络 OSI 七层模型和 TCP 五层模型
  • 【Linux系列】如何在 Linux 服务器上快速获取公网
  • 遥感数据介绍——MODIS、VIIRS、Sentinel-2
  • 飞算JavaAI结合Redis实现高性能存储:从数据瓶颈到极速读写的实战之旅
  • 三种变量类型在局部与全局作用域的区别
  • 大模型算法岗面试准备经验分享
  • 【Linux网络编程】NAT、代理服务、内网穿透
  • css中 hsl() 的用法
  • Java-I18n
  • 43 C++ STL模板库12-容器4-容器适配器-堆栈(stack)
  • 百度笔试编程题 选数
  • PWM控制LED亮度:用户态驱动开发详解
  • Soundraw - 你的AI音乐生成器
  • 51单片机-驱动静态数码管和动态数码管模块
  • linux线程被中断打断,不会计入调度次数
  • 解决 SECURE_PCI_CONFIG_SPACE_ACCESS_VIOLATION蓝屏报错
  • 攻防世界—unseping(反序列化)
  • 机器学习----PCA降维
  • RocketMQ面试题-未完
  • 芋道RBAC实现介绍
  • python+flask后端开发~项目实战 | 博客问答项目--模块化文件架构的基础搭建
  • Valgrind 并发调试 ·:用 Helgrind 抓住线程里的“看不见的错”
  • 数据结构:在二叉搜索树中插入元素(Insert in a BST)
  • linux-高级IO(上)
  • 猫头虎AI分享|一款Coze、Dify类开源AI应用超级智能体Agent快速构建工具:FastbuildAI
  • #买硬盘欲安装k8s记
  • Flutter 3.35 更新要点解析
  • ICCV 2025 | Reverse Convolution and Its Applications to Image Restoration
  • 如何运用好DeepSeek为自己服务:智能增强的范式革命 1.2 DeepSeek认知增强模型