一个小例子说说什么是线程安全
并发是Java编程的基础,在我们日常的工作中,很多时候都会跟并发打交道,当然,这也是面试考察的重点。在并发编程中,被提起最多的概念是线程安全
,下面我们先来看一段代码,看看运行后会发生什么:
public class Test {
private static int inc = 0;
public static void main(String[] args) {
// 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值
CountDownLatch countDownLatch = new CountDownLatch(1000000);
// 设置100个线程同时执行
for (int i = 0; i < 100; i++) {
new Thread(() -> {
// 循环10000次,对inc实现 +1 操作
for (int j = 0; j < 10000; j++) {
inc++;
countDownLatch.countDown();
}
}).start();
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 运行完毕,期望获取的结果是 1000000
System.out.println("执行完毕,inc的值为:" + inc);
}
}
程序中,我创建了100个线程,每个线程中对共享变量inc
进行累加10000次的操作,如果是同步执行的话,inc最终的值应该是1000000,但我们知道在多线程中,程序是并发执行的,也就是说不同的线程可能会同时读取到主内存相同的值,比如这样的场景:
- 线程A在某一个瞬间读取了主内存的inc值为1000,它在自己的工作内存 +1,inc变成了1001;
- 线程B在同样的瞬间读取到了主内存的inc值为1000,它也在自己的工作内存中对inc的值 +1, inc变成了1001;
- 他们要往主内存写入inc的值的时候并没有做任何的同步控制,所以他们都有可能把自己工作内存的1001写入到主内存;
- 那么很显然主内存在进行两次 +1 操作后,实际的结果只进行了一次 +1,变成了1001。
这就是一个很典型的多线程并发修改共享变量带来的问题,那么很显然,它的运行结果也如我们分析的那样,某些情况下达不到1000000:
执行完毕,inc的值为:962370
有些人说通过volatile关键字可以解决这个问题,因为volatile可以保证线程之间的可见性,也就是说线程可以读取到主内存最新的变量值,然后对其进行操作。
注意了,volatile只能保证线程的可见性
,而不能保证线程操作的原子性
,虽然线程读取到了主内存的inc的最新值,但是 读取
、inc+1
、写入主内存
是三步操作,所以volatile无法解决共享变量线程安全的问题。
那么如何解决这个问题?Java为我们提供下面几种解决方案。
几种保证共享变量线程安全的方案
1. 通过synchronized关键字实现同步:
public class Test {
private static int inc = 0;
public static void main(String[] args) {
// 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值
CountDownLatch countDownLatch = new CountDownLatch(1000000);
// 设置100个线程同时执行
for (int i = 0; i < 100; i++) {
new Thread(() -> {
// 循环10000次,对inc实现 +1 操作
for (int j = 0; j < 10000; j++) {
// 设置同步机制,让inc按照顺序执行
synchronized (Test.class) {
inc++;
}
countDownLatch.countDown();
}
}).start();
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行完毕,inc的值为:" + inc);
}
}
在上面的代码中,我们给 inc ++
外面加了一层代码,使用 synchronized
设置类锁
,保证了代码的同步执行,这是一种基于JVM自身的机制来保障线程的安全性,如果在并发量比较大的情况下,synchronized 会升级为重量级的锁,效率很低。synchronized无法获取当前线程的锁状态,发生异常的情况下会自动解锁,但是如果线程发生阻塞,它是不会释放锁的
执行结果:
执行完毕,inc的值为:1000000
可以看到,这种方式是可以保证线程安全的。
2. 通过Lock锁实现同步
public class Test {
private static int inc = 0;
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
// 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值
CountDownLatch countDownLatch = new CountDownLatch(1000000);
// 设置100个线程同时执行
for (int i = 0; i < 100; i++) {
new Thread(() -> {
// 循环10000次,对inc实现 +1 操作
for (int j = 0; j < 10000; j++) {
// 设置锁
lock.lock();
try {
inc++;
} finally {
// 解锁
lock.unlock();
}
countDownLatch.countDown();
}
}).start();
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行完毕,inc的值为:" + inc);
}
}
ReentrantLock
的底层是通过AQS + CAS
来实现的,在并发量比较小的情况下,它的性能不如 synchronized
,但是随着并发量的增大,它的性能会越来越好,达到一定量级会完全碾压synchronized
。并且Lock是可以尝试获取锁的,它通过代码手动去控制解锁,这点需要格外注意。
3. 使用 Atomic 原子类
public class Test {
private static AtomicInteger inc = new AtomicInteger();
public static void main(String[] args) {
// 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值
CountDownLatch countDownLatch = new CountDownLatch(1000000);
// 设置100个线程同时执行
for (int i = 0; i < 100; i++) {
new Thread(() -> {
// 循环10000次,对inc实现 +1 操作
for (int j = 0; j < 10000; j++) {
inc.getAndAdd(1);
countDownLatch.countDown();
}
}).start();
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行完毕,inc的值为:" + inc.get());
}
}
AtomicInteger
底层是基于 CAS 的乐观锁实现的,CAS是一种无锁技术,相对于前面的方案,它的效率更高一些,在下面会详细介绍。
执行结果:
执行完毕,inc的值为:1000000
4. 使用 LongAdder 原子类
public class Test {
private static LongAdder inc = new LongAdder();
public static void main(String[] args) {
// 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值
CountDownLatch countDownLatch = new CountDownLatch(1000000);
// 设置100个线程同时执行
for (int i = 0; i < 100; i++) {
new Thread(() -> {
// 循环10000次,对inc实现 +1 操作
for (int j = 0; j < 10000; j++) {
inc.increment();
countDownLatch.countDown();
}
}).start();
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行完毕,inc的值为:" + inc.intValue());
}
}
LongAdder 原子类在 JDK1.8 中新增的类,其底层也是基于 CAS 机制实现的。适合于高并发场景下,特别是写大于读的场景,相较于 AtomicInteger、AtomicLong 性能更好,代价是消耗更多的空间,以空间换时间。
执行结果:
执行完毕,inc的值为:1000000
CAS理论
讲到现在,终于我们今天的主角要登场了,她就是CAS
。
CAS的意思是比较与交换(Compare And Swap),它是乐观锁的一种实现机制。
什么是乐观锁?通俗的来说就是它比较乐观,每次在修改变量的值之前不认为别的线程会修改变量,每次都会尝试去获得锁,如果获取失败了,它也会一直等待,直到获取锁为止。说白了,它就是打不死的小强。
而悲观锁呢,顾名思义,就比较悲观了,每次在修改变量前都会认为别人会动这个变量,所以它会把变量锁起来,独占,直到自己修改完毕才会释放锁。说白了,就是比较自私,把好东西藏起来自己偷偷享用,完事了再拿出来给别人。悲观锁会因线程一直阻塞导致系统上下文切换,系统的性能开销大。像之前的synchronized
关键字就是悲观锁的一种实现。
CAS是一种无锁原子算法,它的操作包括三个操作数:需要读写的内存位置(V)、预期原值(A)、新值(B)。仅当 V值等于A值时,才会将V的值设为B,如果V值和A值不同,则说明已经有其他线程做了更新,则当前线程则什么都不做。最后,CAS 返回当前V的真实值。CAS 操作时抱着乐观的态度进行的,它总是认为自己可以成功完成操作。
当多个线程同时使用CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会挂起,仅是被告知失败,并且允许再次尝试,当然也允许实现的线程放弃操作。基于这样的原理,CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰。
与锁相比,使用CAS会使程序看起来更加复杂一些,但由于其非阻塞的,它对死锁问题天生免疫,并且,线程间的相互影响也非常小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,他要比基于锁的方式拥有更优越的性能。
CAS的实现
在Java中,JUC的atomic包下提供了大量基于CAS实现的原子类:
我们以AtomicInteger来举例说明。
AtomicInteger类内部通过一个Unsafe类型的静态不可变的变量unsafe来引用Unsafe的实例。
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
然后,AtomicInteger类用value保存自身的数值,并用get()方法对外提供。注意,它的value是使用volatile修饰的,保证了线程的可见性。
private volatile int value;
/**
* Creates a new AtomicInteger with the given initial value.
*
* @param initialValue the initial value
*/
public AtomicInteger(int initialValue) {
value = initialValue;
}
/**
* Gets the current value.
*
* @return the current value
*/
public final int get() {
return value;
}
一路跟踪incrementAndGet
方法到的末尾可以看到是一个native的方法:
/**
* Atomically increments by one the current value.
*
* @return the updated value
*/
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// getAndAddInt 方法
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
// compareAndSet方法
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
可以看到其实incrementAndGet
内部的原理就是通过compareAndSwapInt
调用底层的机器指令不断比较内存旧值和期望的值,如果比较返回false
就继续循环比较,如果返回true
则将当前的新值赋给内存里的值,本次处理完毕。
由此我们知道,原子类实现的自增操作可以保证原子性的根本原因在于硬件(处理器)的相关指令支持。将语义上需要多步操作的行为通过一条指令来完成,CAS指令可以达到这个目的。
CAS的缺点
- 作为乐观锁的一种实现,当多线程竞争资源激烈的情况下,多个线程会发生自旋等待,会消耗一定的 CPU资源。
- CAS不可避免会出现ABA的问题,关于ABA问题的阐释和解决方案,可以参考我的这篇文章
好了,本期关于CAS的分享就到这里结束了。并发作为Java编程的基石,是一个非常重要的知识点,如果同学们对这块的掌握比较薄弱,希望在读完文章后能自己动手敲敲代码,看看实际的运行效果是不是和文中描述的一样。当然,并发是一个非常大的概念,这里只是抛砖引玉,提到了其中的一个小知识点,给出了我自己学习的一点心得体会,如果有阐述不到位或者错误的地方,请私信我一起讨论,感谢!
我是程序员青戈
,本期的面试问题分享到这里就结束了,想提升自己,进阶大厂的同学一定要关注我的公众号:Java学习指南
,在这里每天会从实际的面试出发带你学习和总结Java相关的知识,帮助你扩充技术栈,提升个人实力。我们下期见~