JVM多线程读写和锁

发布时间:2024年01月23日

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
文章来源:https://blog.csdn.net/jason_bone_/article/details/135795340
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。