问题:两个线程对初始值为 0 的静态变量 i 一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
i++产生JVM字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法
putstatic i // 将修改后的值存入静态变量i
i++产生JVM字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 减法
putstatic i // 将修改后的值存入静态变量i
交错执行的可能导致结果可能为正,也可能为负,也可能为0,为正的情况如下:
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
显而易见,两个线程谁后对静态变量做赋值,另一方的赋值就被覆盖了
解决办法: 想要保证 i++ 和 i-- 代码的原子性,需使用 synchronized 对象锁
问题:main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止?
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
Thread.sleep(1000);
run = false; // 线程t不会如预想的停下来
}
之前说过,JIT应用场景之一就有字段优化,可见于 回顾:JVM类加载
①初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存
②热点字段run渐被缓存至t线程自己的工作内存,以减少对主内存的访问
③main线程对run的更新虽然同步至主内存,但t线程的run永远都是旧值
解决办法: volatile(易变关键字),强制 使用到该变量的线程 到主存中获取它的值
关键字 | 使用场景 | 作用/特点 |
---|---|---|
synchronized | 多个写线程 | 既可以保证代码块的原子性,也同时保证代码块内变量的可见性, 属于重量级操作,性能相对更低 |
volatile | 多读一写 | 可见性 |
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
两个线程对r对象的r1属性值做修改,问能得到哪几种结果?
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结
果为1
情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过
了)
仍有一种情况,导致结果为0,就是:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行num = 2
这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现
解决办法: volatile 修饰的变量,可以禁用指令重排,用其修饰 num变量 或者 ready变量即可
有序性理解:
①指令重排的出发点是在不影响正确性的前提下,可以调整语句的执行顺序
⑤多线程下『指令重排』会影响正确性,例如著名的 double-checked locking 模式实现单例
double-checked locking 模式实现单例中的问题分析:
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) {
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
虽然方法能懒惰实例化并加锁,但是多线程下还是有问题的, INSTANCE = new Singleton() 对应的字节码为:
0: new #2 // class cn/itcast/jvm/t4/Singleton
3: dup
4: invokespecial #3 // Method "<init>":()V
7: putstatic #4 // Field
INSTANCE:Lcn/itcast/jvm/t4/Singleton;
问题就在 4 和 7 两步,正常是对象初始化在将其地址赋值给静态变量,但是可能指令重排,先7后4, 导致的结果就是对象还未来得及执行初始化方法,其地址就先赋给了静态变量,此时另一个线程调用该方法,未初始化的对象直接通过静态变量return出去了,这有问题
时间1 t1 线程执行到 INSTANCE = new Singleton();
时间2 t1 线程分配空间,为Singleton对象生成了引用地址(0 处)
时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null(7 处)
时间4 t2 线程进入getInstance() 方法,发现 INSTANCE != null(synchronized块外),直接
返回 INSTANCE
时间5 t1 线程执行Singleton的构造方法(4 处)
解决办法:对 INSTANCE 使用 volatile 修饰
CAS 即 Compare and Swap ,它体现的一种乐观锁的思想(线程安全),比如多个线程要对一个共享的整型变量执行 +1 操作:
// 需要不断尝试
while(true) {
int 旧值 = 共享变量 ; // 比如拿到了当前值 0
int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1
/*
这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
compareAndSwap 返回 false,重新尝试,直到:
compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
*/
if( compareAndSwap ( 旧值, 结果 )) {
// 成功,退出循环
}
}
简单说: 就是CAS用于检验共享变量的结果达到预期要求, 因此它配上 volatile 修饰变量保证该变量的可见性,可以实现无锁并发,效率提升,缺点就是不达要求不断重试,会争抢资源,效率反而下降,因此它适用于竞争不激烈、CPU多核的场景,不然还是稳妥起见选用synchronized悲观锁
原子操作类, 底层就是采用 CAS 技术 + volatile 来实现的。以 AtomicInteger 为例:
// 创建原子整数对象
private static AtomicInteger i = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i.getAndIncrement(); // 获取并且自增 i++
// i.incrementAndGet(); // 自增并且获取 ++i
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i.getAndDecrement(); // 获取并且自减 i--
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
每个对象都有对象头(包括 class 指针和 Mark Word)。Mark Word 平时存储这个对象的 哈希码 、 分代年龄 ,当加锁时,这些信息就根据情况被替换为 标记位 、 线程锁记录指针 、 重量级锁指针 、 线程ID 等内容
反过来,每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word
假设有两个方法同步块,利用同一个对象加锁:
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
线程 1 | 对象 Mark Word | 线程 2 |
---|---|---|
访问同步块 A,把 Mark 复制到 线程 1 的锁记录 | 01(无锁) | |
CAS 修改 Mark 为线程 1 锁记录 地址 | 01(无锁) | |
成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | |
执行同步块 A | 00(轻量锁)线程 1 锁记录地址 | |
访问同步块 B,把 Mark 复制到 线程 1 的锁记录 | 00(轻量锁)线程 1 锁记录地址 | |
CAS 修改 Mark 为线程 1 锁记录 地址 | 00(轻量锁)线程 1 锁记录地址 | |
失败(发现是自己的锁) | 00(轻量锁)线程 1 锁记录地址 | |
锁重入 | 00(轻量锁)线程 1 锁记录地址 | |
执行同步块 B | 00(轻量锁)线程 1 锁记录地址 | |
同步块 B 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | |
同步块 A 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | |
成功(解锁) | 01(无锁) | |
01(无锁) | 访问同步块 A,把 Mark 复制到 线程 2 的锁记录 | |
01(无锁) | CAS 修改 Mark 为线程 2 锁记录 地址 | |
00(轻量锁)线程 2 锁记录地址 | 成功(加锁) | |
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
线程 1 | 对象 Mark Word | 线程 2 |
---|---|---|
访问同步块,把 Mark 复制到 线程 1 的锁记录 | 01(无锁) | |
CAS 修改 Mark 为线程 1 锁记录 地址 | 01(无锁) | |
成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 访问同步块,把 Mark 复制 到线程 2 |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为线程 2 锁 记录地址 |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 失败(发现别人已经占了 锁) |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为重量锁 |
执行同步块 | 10(重量锁)重量锁指 针 | 阻塞中 |
执行完毕 | 10(重量锁)重量锁指 针 | 阻塞中 |
失败(解锁) | 10(重量锁)重量锁指 针 | 阻塞中 |
释放重量锁,唤起阻塞线程竞争 | 01(无锁) | 阻塞中 |
10(重量锁) | 竞争重量锁 | |
10(重量锁) | 成功(加锁) |
重量级锁竞争的时候,还可以使用自旋来进行优化
如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
如果自旋重试失败则线程阻塞
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS.
优化 | 操作 |
---|---|
减少上锁时间 | 同步代码块中尽量短 |
减少锁的粒度 | 将一个锁拆分为多个锁提高并发度 |
锁粗化 | 多次循环进入同步块不如同步块内多次循环 |
锁消除 | JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候 就会被即时编译器忽略掉所有同步操作。 |
读写分离 | CopyOnWriteArrayList ConyOnWriteSet |