JVM多线程读写和锁

本文详细探讨了Java并发编程中的原子性、可见性、有序性以及CAS、synchronized等同步机制,包括轻量级锁、锁膨胀、自旋锁和偏向锁的原理与优化。通过实例和解释,揭示了多线程环境下可能出现的问题以及如何通过恰当的技术确保程序的正确执行。
摘要由CSDN通过智能技术生成

1 原子性

问题:两个线程对初始值为 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 对象锁

2 可见性

问题: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多读一写可见性

3 有序性

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 != null7 处)
时间4 t2 线程进入getInstance() 方法,发现 INSTANCE != nullsynchronized块外),直接
返回 INSTANCE
时间5 t1 线程执行Singleton的构造方法(4 处)

解决办法:对 INSTANCE 使用 volatile 修饰

4 CAS

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);
}

5 synchronized 优化

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

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

5.1 轻量级锁

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

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
锁记录地址
执行同步块 A00(轻量锁)线程 1
锁记录地址
访问同步块 B,把 Mark 复制到
线程 1 的锁记录
00(轻量锁)线程 1
锁记录地址
CAS 修改 Mark 为线程 1 锁记录
地址
00(轻量锁)线程 1
锁记录地址
失败(发现是自己的锁)00(轻量锁)线程 1
锁记录地址
锁重入00(轻量锁)线程 1
锁记录地址
执行同步块 B00(轻量锁)线程 1
锁记录地址
同步块 B 执行完毕00(轻量锁)线程 1
锁记录地址
同步块 A 执行完毕00(轻量锁)线程 1
锁记录地址
成功(解锁)01(无锁)
01(无锁)访问同步块 A,把 Mark 复制到
线程 2 的锁记录
01(无锁)CAS 修改 Mark 为线程 2 锁记录
地址
00(轻量锁)线程 2
锁记录地址
成功(加锁)

5.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(重量锁)成功(加锁)

5.3 自旋

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

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

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

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

5.4 偏向锁

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

5.5 其他优化

优化操作
减少上锁时间同步代码块中尽量短
减少锁的粒度将一个锁拆分为多个锁提高并发度
锁粗化多次循环进入同步块不如同步块内多次循环
锁消除JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候
就会被即时编译器忽略掉所有同步操作。
读写分离CopyOnWriteArrayList
ConyOnWriteSet
  • 21
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值