JVM多线程读写和锁

文章目录

  • [1 原子性](#1 原子性)
  • [2 可见性](#2 可见性)
  • [3 有序性](#3 有序性)
  • [4 CAS](#4 CAS)
  • [5 synchronized 优化](#5 synchronized 优化)
    • [5.1 轻量级锁](#5.1 轻量级锁)
    • [5.2 锁膨胀](#5.2 锁膨胀)
    • [5.3 自旋](#5.3 自旋)
    • [5.4 偏向锁](#5.4 偏向锁)
    • [5.5 其他优化](#5.5 其他优化)

1 原子性

问题:两个线程对初始值为 0 的静态变量 i 一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

i++产生JVM字节码指令:

java 复制代码
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法
putstatic i // 将修改后的值存入静态变量i

i++产生JVM字节码指令:

java 复制代码
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 减法
putstatic i // 将修改后的值存入静态变量i

交错执行的可能导致结果可能为正,也可能为负,也可能为0,为正的情况如下:

java 复制代码
// 假设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 对象锁

2 可见性

问题:main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止?

java 复制代码
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 多读一写 可见性

3 有序性

java 复制代码
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 模式实现单例中的问题分析:

java 复制代码
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() 对应的字节码为:

java 复制代码
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出去了,这有问题

java 复制代码
时间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 修饰

4 CAS

CAS 即 Compare and Swap ,它体现的一种乐观锁的思想(线程安全),比如多个线程要对一个共享的整型变量执行 +1 操作:

java 复制代码
// 需要不断尝试
while(true) {
	int 旧值 = 共享变量 ; // 比如拿到了当前值 0
	int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1

    /*
	这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
	compareAndSwap 返回 false,重新尝试,直到:
	compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
	*/
	if( compareAndSwap ( 旧值, 结果 )) {
		// 成功,退出循环
	}
}

简单说: 就是CAS用于检验共享变量的结果达到预期要求, 因此它配上 volatile 修饰变量保证该变量的可见性,可以实现无锁并发,效率提升,缺点就是不达要求不断重试,会争抢资源,效率反而下降,因此它适用于竞争不激烈、CPU多核的场景,不然还是稳妥起见选用synchronized悲观锁

原子操作类, 底层就是采用 CAS 技术 + volatile 来实现的。以 AtomicInteger 为例:

java 复制代码
// 创建原子整数对象
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);
}

5 synchronized 优化

每个对象都有对象头(包括 class 指针和 Mark Word)。Mark Word 平时存储这个对象的 哈希码 、 分代年龄 ,当加锁时,这些信息就根据情况被替换为 标记位 、 线程锁记录指针 、 重量级锁指针 、 线程ID 等内容

反过来,每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word

5.1 轻量级锁

假设有两个方法同步块,利用同一个对象加锁:

java 复制代码
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 锁记录地址 成功(加锁)

5.2 锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁

java 复制代码
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(重量锁) 成功(加锁)

5.3 自旋

重量级锁竞争的时候,还可以使用自旋来进行优化

如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

如果自旋重试失败则线程阻塞

自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。

5.4 偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS.

5.5 其他优化

优化 操作
减少上锁时间 同步代码块中尽量短
减少锁的粒度 将一个锁拆分为多个锁提高并发度
锁粗化 多次循环进入同步块不如同步块内多次循环
锁消除 JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候 就会被即时编译器忽略掉所有同步操作。
读写分离 CopyOnWriteArrayList ConyOnWriteSet
相关推荐
白总Server1 小时前
API架构解说
java·网络·jvm·物联网·安全·web安全·架构
rosener2 小时前
pom中无法下载下来的类外部引用只给一个jar的时候
java·jar
黄名富3 小时前
SQL 语句优化及编程方法
java·数据库·mysql
follycat3 小时前
ISCTF2024
java·网络·数据库·学习·网络安全·python3.11
baozhengw3 小时前
IntelliJ+SpringBoot项目实战(七)--在SpringBoot中整合Redis
java·spring boot·redis
服务端相声演员5 小时前
IOException: Broken pipe与IOException: 远程主机强迫关闭了一个现有的连接
java·服务器·网络
灰阳阳5 小时前
2022年蓝桥杯JavaB组 省赛 题目解析(含AC_Code)
java·职场和发展·蓝桥杯·省赛·超详解
皎味小行家5 小时前
第三十二天|动态规划| 理论基础,509. 斐波那契数,70. 爬楼梯 ,746. 使用最小花费爬楼梯
java·数据结构·算法·leetcode·动态规划
MrJson-架构师5 小时前
java 操作Mongodb
java·开发语言·mongodb
qq_35323353895 小时前
【原创】java+ssm+mysql美食论坛网系统设计与实现
java·mysql·美食