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. 性能比较
类型 | 举例 | 特点 |
---|---|---|
线程不安全(快) | ArrayList , HashMap | 多线程用会出错 |
线程安全(慢) | Vector , Hashtable , synchronizedList | 加锁,安全但慢 |
建议:如果确定是单线程环境,用“快但不安全”的类;如果是多线程共享数据,一定要用“安全”的方式。
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),
这行代码其实不是原子操作,它分三步:
- 读
counter
的值 - +1
- 写回去
两个线程可能同时读到同一个值,比如都读到 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 对象,只有在当前实例对象的线程内是安全的,如果有多个实例就不安全
方法类型 | 锁的是谁? | 白话解释 |
---|---|---|
普通方法加 synchronized | this (当前对象) | 谁调用,就锁谁这个“人” |
静态方法加 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 的对象不会关联监视器,不遵从以上规则