Java synchronized 与 CAS(Compare And Swap)

4 篇文章 0 订阅
本文深入探讨了Java中的线程安全性问题,通过一个并发计数器的例子引出临界区和竞态条件的概念。文章详细分析了synchronized的锁机制,包括偏向锁、轻量级锁和重量级锁,以及锁膨胀的过程。同时,介绍了无锁并发的CAS(CompareAndSwap)机制,以及其在多线程环境下的应用和局限性。此外,还讨论了ABA问题和原子引用,以及LongAdder在高并发累加场景下的性能优势。
摘要由CSDN通过智能技术生成

线程安全性分析

在系统处理过程中,最为常见的问题是多个线程对于相同资源进行访问所造成的数据处理异常问题。

看下面的例子:

public static int counter;

public static void main(String[] args) {
	Thread t1 = new Thread(() -> {
		for (int i = 0; i < 10000; i++) {
			counter++;
		}
	});
	Thread t2 = new Thread(() -> {
		for (int i = 0; i < 10000; i++) {
			counter--;
		}
	});	
	t1.start();
	t2.start();
	t1.join();
	t2.join();
	
	System.out.println(counter);
}

执行结果:
counter 不为 0,可能正数可能负数

执行结果并不是我们预期的结果为 0,可能是一个随机的数值。

或许你会想:我给 counter 变量加上 volatile 是不是就可以解决问题了?很遗憾 volatile 是解决的可见性和有序性的问题,它并不是用于解决当前场景的。

为什么会这样呢?看下 counter++ 这句代码的字节码:

getstatic #9 
iconst_1
iadd
putstatic #9

可以发现,++ 操作不是单纯的原子操作(原子操作即 i = 1 只有一条指令),而是分为了三个步骤:获取数据、计算、重新赋值。从指令层面来说它由四个指令完成一次加完之后的赋值
在这里插入图片描述
CPU 时间片轮转机制并发运行线程,因为 ++ 操作实际上是执行的四个指令并不是原子操作,所以就可能出现线程在时间片内没有执行完指令就切换了线程,后续重新抢到时间片再处理后续的指令,出现了复写的情况。本质原因就是线程上下文切换导致

从这个问题可以引申出来两个概念:临界区和竞态条件。

  • 临界区:一个程序运行多个线程本身没有问题,出现问题最大的地方在于多个线程访问了共享资源。多个线程读共享资源没有问题,在多个线程对共享资源读写操作时发生指令交错,就会出现问题。一段代码内如果存在对共享资源的多线程读写操作,称这段代码为临界区

  • 竞态条件:多个线程在临界区内执行,由于代码执行序列不同而导致结果无法预测,称之为竞态条件

按上面的代码示例,counter++、counter-- 就是被多个线程读写操作,就是临界区。

为了避免临界区的竞态条件发生,Java 提供了多种手段进行规避:

  • 阻塞式解决方案:synchronized、Lock

  • 非阻塞式解决方案:原子变量

线程上下文切换

CPU 切换前把当前任务的状态保存下来,以便下次切换回这个任务时可以再次加载这个任务的状态,然后加载下一任务的状态并执行。任务的状态保存及再加载,这段过程就叫做上下文切换

上下文切换会导致额外的开销,常常表现为高并发执行时速度会慢串行,因此减少上下文切换次数便可以提高多线程程序的运行效率。

有锁并发:synchronized

Java 为了避免临界区的竞态条件发生提供的阻塞式解决方案,其中就有 synchronized。接下来用 synchronized 解决问题:

private static int counter = 0;

public static void main(String[] args) {
	// 充当锁
	Object obj = new Object();
	// byte[] obj = new byte[0]; // byte[]数组也可以作为锁,更省空间

	Thread t1 = new Thread(() -> {
		for (int i = 0; i < 10000; i++) {
			synchronized(obj) {
				counter++;
			}
		}
	});	
	Thread t2 = new Thread(() -> {
		for (int i = 0; i < 10000; i++) {
			synchronized(obj) {
				counter--;
			}
		}
	});
	t1.start();
	t2.start();
	t1.join();
	t2.join();
	
	System.out.println(counter);
}

执行结果:
0

上面是用的 synchronized 同步代码块的方式实现了同步,在该场景下 synchronized 实现了线程的同步。

我们在这里将问题再深度剖析下:为什么 synchronized 能解决这种场景的问题?它是怎么做到的?synchronized 同步代码块为什么需要传入对象作为锁呢?接下来一一解析。

对象中的 mark word 对并发的支持

在 Java 中我们会使用对象作为锁,这其实是因为 JVM 底层实现过程中,对于锁的判断依据全部都扔到了对象上面进行判定。

锁会在对象上判定,那具体是怎么判定的?

在这里插入图片描述

一个对象是由上面几个部分组成的,而其中 32 bit 的 mark word 有锁标志位:

在这里插入图片描述

可以看到,锁有分为三种:偏向锁、轻量级锁和重量级锁,而且不同的锁在 mark word 记录的信息也不相同:

在这里插入图片描述

现在可以思考一个问题:为什么要分三种锁?而锁又是怎么切换的?

需要回答上面的问题要先分析下三种锁的区别是什么以及它们的工作原理。

重量级锁

在这里插入图片描述

可以看上图,重量级锁其实是一个 Monitor 对象,其中有 waitSet、entryList 和 Owner,waitSet 记录的休眠中的线程,entryList 记录等待锁的线程,Owner 则是持有锁正在运行的线程。

  • 使用对象作为锁时,会向 JVM 申请一把锁,也就是 Monitor(Monitor 就是一个锁对象)

  • 将申请的 Monitor 地址指针记录在对象的 mark word(也就是图上的【指向互斥量的指针】)

  • 拿到锁的线程可以运行(Owner),没有拿到锁的线程会在 entryList 队列容器等待,等到持锁的线程退出时,等待的线程会竞争抢锁

  • 休眠的线程会在 waitSet 集合等待,当通过 notify()/notifyAll() 通知时被唤醒,然后进入 entryList 队列容器或拿到锁运行

在网上搜索锁相关的资料也会有提及到公平锁和非公平锁:

  • 非公平锁:在锁可用的时候,一个新到来的线程要占有锁,可以不需要排队,直接获得

  • 公平锁:在锁可用的时候,一个新到来的线程要占有锁,需要排队,等待执行

简单理解公平锁和非公平锁的区别,就是在 Monitor entryList 的线程是要按顺序到 Owner 还是竞争方式计算后让线程到 Owner

执行 synchronized 同步代码块的内容,唤醒 entryList 中其他线程时,此处采取竞争策略,先到不一定先得,所以 synchronized 锁是非公平锁

偏向锁

在这里插入图片描述

每个线程都分配一个虚拟机栈(涉及到 JVM 知识点,具体可查看 JVM 运行时数据区(堆和栈)),线程的执行单元就是栈帧。

  • 在线程栈帧记录了 LockRecord,此时对象中 LockRecord 锁标志位是 01(偏向锁)

  • 将对象的 mark word 和对象引用放到线程栈帧(因为对象要存锁相关信息,所以只能将对象的其他相关信息例如 hashcode 等存放到栈区)

  • 如果线程释放了锁,就将对象的 mark word 和对象引用归还,并将线程栈帧的 LockRecord 移除

轻量级锁

在这里插入图片描述
在偏向锁的基础上,轻量级锁将 mark word 中锁标记位修改为 00,两个线程会有锁竞争。

锁膨胀

上面对三种锁进行了分析,当我们在代码中添加了 synchronized 时,三种锁会在怎样的场景下切换的?

public static int counter = 0;

public static void main(String[] args) {
	Object obj = new Object();
	
	// 只有一个线程在运行,并且加了锁
	Thread t1 = new Thread(() -> {
		for (int i = 0; i < 10000; i++) {
			synchronized(obj) {
				counter++;
			}
		}
	});
	t1.start();
	t1.join();
	System.out.println(counter);
}

因为重量级锁使用 Monitor 管理对资源的开销较大,所以不同的场景下使用 synchronized 会是不同的锁方案:

  • 偏向锁:单个线程在运行,直接标记在对象的 mark word 作为标记识别

  • 轻量级锁:两个线程在运行,可能是线程 1 可能是线程 2,也不需要 Monitor,但需要有地方存放两个线程的数据,数据会放在线程栈帧里面

  • 重量级锁:多个线程在运行,需要 Monitor 支持

在这里插入图片描述

线程慢慢开辟的过程中,从偏向锁到轻量级锁再到重量级锁,锁处理方案变更的这个过程叫做锁膨胀

无锁并发:CAS(Compare And Swap)

在例子中的场景,无论是使用哪种锁都会涉及对数据的修改迁移等操作。在例子中的场景有没有一种比 synchornized 更优的方案能解决上面的问题?

CPU 提供了 CAS 指令,同时借助 JNI 来完成 Java 的非阻塞算法,其他原子操作都是利用类似的特性完成的。

为了方便理解 CAS(Compare And Swap,比较并替换)机制,我们用三个变量说明 CAS 当中使用的三个基本操作数:内存地址 V、旧的预期值 A、要修改的新值 B。

更新一个变量的时候,只有当变量的预期值 A 和内存地址 V 当中的实际值相同时,才会将内存地址 V 对应的值修改为新值 B

用一个例子简单理解 CAS 的原理(下图从左到右说明):

在这里插入图片描述

  • 图中线程 1 和线程 2 分别从内存中读取了 i = 10 到工作内存,并且计算修改了数值 i = 11(旧的预期值 i = 10,新值 i = 11)

  • 但因 CPU 时间片轮转机制,线程 2 提前将内存地址中的变量 i 更新为 11

  • 线程 1 判断到 10 != 11(CAS),由于值经过 volatile 修饰立马就知道,此时判断不等于实际值,所以不会写入主内存,而是重新从主内存读取新值

  • 线程 1 在后续计算操作再尝试写入进行 Compare,发现数值和内存地址中实际值是相等的,所以可以写入

CAS 是一种策略,这个策略是为了保证主内存中的数据在被多个线程赋值使用时是准确的。为了达到这个目的,它采取的方案是,把旧值保留,拿旧值与主内存的数值比对,如果不一致就重新读取再加载。所以 CAS 它是无锁的。

需要注意的是,CAS 必须和 volatile 配合使用,在保证线程安全的前提下,需要具有可见性。因为对比时能知道自己线程中的工作内存的数值是否失效了。

在 Java 中 ReentrantLock 或 AtomicXxx api 就是基于 CAS 理论实现的:

private static ReentrantLock lock = new ReentrantLock();
private static int counter = 0;

public static void main(String[] args) {
	Thread t1 = new Thead(() -> {
		for (int i = 0; i < 10000; i++) {
			lock.lock();
			counter++;
			lock.unlock();
		}
	});
	Thread t2 = new Thread(() -> {
		for (int i = 0; i < 10000; i++) {
			lock.lock();
			counter--;
			lock.unlock();
		}
	});
	t1.start();
	t2.start();
	t1.join();
	t2.join();

	System.out.println(counter);
}

执行结果:
0

CAS 的使用场景

在说明 CAS 原理时特别提到,在例子的应用场景下比 synchronized 更优的方案,因为 CAS 并不是在所有场景下效率都比 synchornized 高

单纯的 CAS 理论只是为了完成一次比较确认值的同步,与代码块的同步并没有关系。

在 CAS 理论应用下的锁实现原理是:利用 volatile 变量与 CAS 理论保证在一定时间段内变量结果的一致性,同步对于线程进行阻塞

怎么理解 CAS 理论说的完成一次比较确认值的同步?这里用 AtomicInteger 的源码说明:

AtomicInteger.java

public final int incrementAndGet() {
	return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

Unsafe.java

public final int getAndAddInt(Object var1, long var2, int var4) {
	int var5;
	// 在死循环中比较确认,所以 CAS 就是无锁的
	do {
		var5 = getIntVolatile(var1, var2);
	} while (!compareAndSwapInt(var1, var2, var5, var5 + var4)); 
	
	return var5; // 符合条件才返回
}

CAS 与 synchronized 的本质区别(CAS 就是无锁的,synchronized 是有锁的)

  • 无锁情况下,即使重试失败了,线程始终在高速运行没有停歇,而 synchronized 会让线程在没有获得锁的时候发生上下文切换进入阻塞

  • 无锁状态下,因为线程需要保持运行,则需要额外的 CPU 支持,但因为没有锁不会阻塞,如果没有时间片还是会导致线程上下文切换,所以 CAS 需要有多核 CPU 的支撑,单核体系下效率不一定高

通过 AtomicInteger 的源码可以了解到,它的实现就是在对应线程下开启了一个死循环不断的做比较确认,所以 在 CAS 的线程始终在运行没有释放 CPU 资源

CAS 体现的是无锁并发,无阻塞并发,因为没有 synchronized,线程不会陷入阻塞,这是效率提升的因素之一;如果有竞争的几率,重试必然频繁发生,效率则会下降。

使用 CAS 就是最大限度的保证不进行线程上下文的切换提高运行效率。

所以结合 CAS 与 volatile 实现无锁并发情况的适用场景是:多核 CPU 场景下且线程数较少,最好是线程数不超过 CPU 核心数时,这种场景使用该方案效率优于 synchronized。

网上也有很多讲解所谓的乐观锁和悲观锁的概念,在这里也可以总结一下:

  • 加锁并发:使用 synchronized 就是悲观锁(得防着其他线程来修改共享变量,我上锁,你们都别改,我改了解开你们才有机会)

  • 无锁并发:CAS 应用实现就是乐观锁(不怕别的线程来修改共享变量,改了也没事,我再重试)

原子引用与 ABA 问题

ABA 问题是在多线程对于原子变量操作时,会发生将数据变更回去的现象,CAS 在判断时会造成概念上的认知错误,但实际上对业务结果是不变的

在实际业务使用过程中可能会需要知道整个运行过程中值是否改变,可以通过 AtomicStampedReference 追溯版本号,通过 AtomicMarkableReference 得到是否更改结果。

public class Test {
	private static AtomicReference<String> ref = new AtomicReference<>("A");
	private static AtomicStampedReference<String> ref2 = new AtomicStampedReference<>("A", 0);
	private static AtomicMarkableReference<String> ref3 = new AtomicMarkableReference<>("A", false);

	public static void main(String[] args) throws InterruptedException {
		System.out.println("main start...");
		String prev = ref.get();
		other();
		Thread.sleep(1000);
		System.out.println("change A->C" + ref.compareAndSet(prev, "C"));
	}

	private static void other() {
		// 更改为B
		new Thread(() -> {
			System.out.println("change A->B" + ref.compareAndSet(ref.get(), "B"));
		});
		// 改回A
		new Thread(() -> {
			System.out.println("change B->A" + ref.compareAndSet(ref.get(), "A"));
		});
	}
}

原子累加器

LongAdder 是专门用于做数据累加和递减的工具类,相比直接使用 AtomicLong,执行效率上在不同的 CPU 核数下会成倍的提升。

public static void main(String[] args) {
	testAtomicLong();
	testLongAddr();
}

private static void testAtomicLong() {
	AtomicLong atomicLong = new AtomicLong(0);
	List<Thread> ts = new ArrayList<>();
	
	long begin = System.nanoTime();
	for (int i = 0; i < 4; i++) {
		ts.add(new Thread(() -> {
			for (int j = 0; j < 1000000; j++) {
				atomicLong.incrementAndGet();
			}
		}););	
	}
	
	ts.forEach(t -> t.start());
	ts.forEach(t -> {
		try {
			t.join();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	});
	
	long end = System.nanoTime();
	System.out.println(atomicLong.get() + ", time = " + (end - begin));
}

private static void testLongAddr() {
	LongAdder longAdder = new LongAdder(0);
	List<Thread> ts = new ArrayList<>();
	
	long begin = System.nanoTime();
	for (int i = 0; i < 4; i++) {
		ts.add(new Thread(() -> {
			for (int j = 0; j < 1000000; j++) {
				longAdder.add();
			}
		}););	
	}
	
	ts.forEach(t -> t.start());
	ts.forEach(t -> {
		try {
			t.join();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	});

	long end = System.nanoTime();
	System.out.println(longAdder.longValue() + ", time = " + (end - begin));	
}

执行结果:
4000000, time = 127771800
4000000, time = 40798900

LongAdder 是怎么做到性能提升的?

在这里插入图片描述

public void add(long x) {
    Cell[] as; long b, v; int m; Cell a;
    if ((as = cells) != null || !casBase(b = base, b + x)) {
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[getProbe() & m]) == null ||
            !(uncontended = a.cas(v = a.value, v + x)))
            longAccumulate(x, null, uncontended);
    }
}

public long longValue() {
    return sum();
}

public long sum() {
    Cell[] as = cells;
    long sum = base;
    if (as != null) {
        for (Cell a : as)
            if (a != null)
                sum += a.value;
    }
    return sum;
}

// 添加了注解
@sun.misc.Contended static final class Cell {
	volatile long value;
	Cell(long x) { value = x; }
	final boolean cas(long cmp, long val) {
		return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
	}
	...
}

可以看到 LongAdder 使用 Cell 对象存储了一个 long,并且添加了一个注解 @sun.misc.Contended;不同的地方在于,获取最终结果时是循环 Cell 数组累加获得的结果。

也许你会有疑问:这里为什么会使用一个 Cell 对象数组?用一个 long 数组能否达到同样的效果?答案是不行的。这里涉及到一个伪共享问题。

在这里插入图片描述

在 CPU 高速缓冲区的存储体系下,一个基本的缓存单位叫缓存行,一个缓存行的大小为 64 bytes。数组是一块连续的空间,因为副本数据的原因,数组加载到缓存当中时,数据超过 64 bytes 会占用多行,若小于 64 bytes 则占用一行。

Cell 是数组形式,在内存中连续,一个 Cell 为 24 bytes(16 字节头+ 8 字节内容),因此缓存行可以存下 2 个 Cell 对象。

图中因为 Cell 数组小于缓存行一行 64 bytes 的大小,所以高速缓冲区会直接将整个数组读取进来。在不同的线程修改了 C1 或 C2 会触发总线嗅探机制告知另一个线程的修改无效,这就是伪共享问题。

Cell 对象标记了注解 @sun.misc.Contended,它的作用是在注解对象或字段前后各增加 128 bytes 大小的 padding,从而让 CPU 将对象读取至缓存时占用不同的缓存行,即 C1 和 C2 分别在不同的缓存行中,不会被高速缓冲区按数组为单位读取加载,解决了伪共享的问题。

LongAdder 性能提升的原因很简单,就是有竞争时,设置多个累加单元,然后将结果汇总,累加不同的 cell 变量,减少了 CAS 重试失败从而提高性能。

总结

我们从一个简单的多线程并发问题引出了 CPU 时间分片机制和线程上下文切换,进而引申出导致该问题的临界区和竞态条件,解决这种并发问题主要有两种方式:synchronized 有锁并发和基于 CAS 理论的无锁并发。

其中有锁并发 synchronized 在不同的线程并发场景会存在偏向锁、轻量级锁和重量级锁不断进行锁膨胀的过程。没有持锁的线程会阻塞释放 CPU 资源。

而 CAS 理论需要结合 volatile 可见性配合使用才能生效,它通过 volatile 可见性将主内存的数值和线程工作内存中的预期值比对的方式解决并发问题,但 CAS 在 CPU 多核情况下才能很好的发挥它的作用。CAS 和 volatile 可见性的方式是不持锁的,但比对过程会不断重试占用 CPU 资源,直到线程中的工作内存的预期值和实际值相同才写回主内存。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值