参考:
https://blog.csdn.net/Hellowenpan/article/details/103202898
https://blog.csdn.net/v123411739/article/details/79561458
https://csp1999.blog.csdn.net/article/details/116136920
https://www.cnblogs.com/xd502djj/p/9873067.html
https://blog.csdn.net/weixin_30342639/article/details/91356608
https://blog.csdn.net/qq_35190492/article/details/104691668
写在前面:很多人(包括我)刚了解多线程这个概念的时候,对并发的概念理解是很模糊的,也不清楚所谓的线程安全问题到底是什么意思,这些概念是必须要明确清晰的。
而集合类中的类似ConcurrentHashMap又必须了解多线程的基础知识,先补基础,再学源码
关于线程的状态,和概念,可以参考这里
多线程基础总结_future_xiaowu的博客-CSDN博客
也建议先看一下synchronized锁的相关知识,这位大佬分析的很透彻,关于锁的原理,和锁为什么对性能的影响大,chen.yu大佬的评论同样精彩,ReentrantLock与synchronized性能差异的原因的评论可以充分地让你在面试中装逼。
深入理解Java并发之synchronized实现原理_zejian的博客-CSDN博客_synchronized原理
线程是执行具体任务的存在,产生线程安全的前提是有竞态条件,说白了,就是多个线程操作一个一个相同的变量的时候才会产生安全问题。
要保证线程安全,操作必须具有原子性,也就是在jvm中不可再拆分的操作
举例,如果你启动了若干个线程分别调用不同的接口,这些接口互不相关,你只是需要它们的返回结果来进行下一步处理,启动线程也只是为了将这些接口异步化,那么这些线程其实是不存在线程安全问题的,每个接口数据返回你拿到数据处理就好
但是,如果你启动多个线程去将一批数据写入DB,假设主键自增,这些线程都操作这批数据,都进行写操作,那么你完全可能将一条数据重复写入多次。
或者,你在类中定义一个整形变量,启动多个线程,每个线程去让这个变量+n,最终得到的结结果很可能是错的。而若你定义若干个变量,每个线程都操作其中一个,则结果又会是正确的。
为什么会产生这种情况?下面我们娓娓道来
线程安全问题
首先,假设我们正设计一个影院售票系统,共计10000张票,售完即止,用线程模拟实际买票的用户,假设有20个售票点,每个售票点都有500张票且全部卖空,我们期望的最终输出余票值是0,但真实输出的却不是0
package com.example.entity;
import java.util.concurrent.CountDownLatch;
public class CasDemo {
private static int totalTickets = 10000;
public static void buy(){
totalTickets--;
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(20);
int threadSize = 20;
for(int i = 0; i < threadSize; i++){
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
for(int i = 0; i < 500; i++){
buy();
}
} catch (Exception e) {
e.printStackTrace();
//这里使用finally包裹,确认执行后标记数减一
//在日常开发中,如同写日志到DB 关闭流操作等也建议放到finally中
} finally {
countDownLatch.countDown();
}
}
});
thread.start();
}
countDownLatch.await();
System.out.println("余票数" + totalTickets);
}
}
输出:
余票数17
补一下CountDownLatch的作用,引自草帽的博客:
CountDownLatch的概念
CountDownLatch是一个同步工具类,用来协调多个线程之间的同步,或者说起到线程之间的通信(而不是用作互斥的作用)。
CountDownLatch能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行。使用一个计数器进行实现。计数器初始值为线程的数量。当每一个线程完成自己任务后,计数器的值就会减一。当计数器的值为0时,表示所有的线程都已经完成了任务,然后在CountDownLatch上等待的线程就可以恢复执行任务。
CountDownLatch的用法
CountDownLatch典型用法1:某一线程在开始运行前等待n个线程执行完毕。将CountDownLatch的计数器初始化为n —> new CountDownLatch(n) ,每当一个任务线程执行完毕,就将计数器减1 —> countdownlatch.countDown(),当计数器的值变为0时,在CountDownLatch上 await() 之后的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
CountDownLatch典型用法2:实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的CountDownLatch(1),将其计数器初始化为1,多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为0,多个线程同时被唤醒。
在我们这里作用就是让主线程等待任务线程都执行完毕后再输出票数
问题来了,为什么会出现大于0的余票数呢?
我们的--操作是非原子操作,实际要分成三步
一 ticket = 10000 获取值
二 ticket-- 减一操作
三 ticket = ticket 更新值
从主存中获取票数,票数减一,将新的票数更新到主存
问题在于,我们没有对线程读取主存数据,回写主存数据的时机进行控制,也没有控制线程的执行方式,会出现若干线程一起执行,假设有10个线程都执行到第一步,它们读取到的票数是一样的,且并行执行了减一,10个线程并行减一,减一后这10个线程中的票数都是一样的,最终它们将数据同步到主存。
没错,10个线程,并行减1,最后总数减掉的不是10,而是1
这里多说一句关于volatile关键字的作用,该关键字能保证内存可见性,但不能保证操作的原子性,也就是说它不能保证线程安全https://blog.csdn.net/weixin_30342639/article/details/91356608
使用这个关键字修饰票数,可以让线程在执行-1前先去读取主存中的票数,但是线程执行是并行的,如果若干线程都走到-1了,且都读取了同样的票数,同时减一,是不是最后只相当于减了一次1?那刷新回主存的数据,一定是比0大的,所以,即便使用volatile关键字修饰票数,最后余票仍旧大于0
如果不加耗时模拟的代码,你有机会得到正确的输出0,这是因为单个线程执行速度极快,线程们“一起执行”的几率不大,而当你增加了耗时模拟的代码,每个线程执行时间变长,它们“碰到一起”的概率就大大增加了。反应在业务代码上,就是不控制线程安全的代码,如果线程方法耗时不长,会出现时而正常时而异常的问题,如果线程方法耗时长,就会一直异常
加锁,保证数据同步
如何解决?
我们最先能想到的是synchronized锁,因为锁一次只允许一个线程进入,线程进入锁之前读取主存,更新自己的数据,出锁后更新主存,将自己的数据写到主存
于是加锁
package com.example.entity;
import java.util.concurrent.CountDownLatch;
public class CasDemo {
private static int totalTickets = 10000;
public synchronized static void buy(){
//模拟售票过程消耗的时间
TimeUnit.MILLISECONDS.sleep(5);
totalTickets--;
}
public static void main(String[] args) throws InterruptedException {
long startTime = System.currentTimeMillis();
CountDownLatch countDownLatch = new CountDownLatch(20);
int threadSize = 20;
for(int i = 0; i < threadSize; i++){
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
for(int i = 0; i < 500; i++){
buy();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
}
});
thread.start();
}
countDownLatch.await();
System.out.println("余票数" + totalTickets);
long endTime = System.currentTimeMillis();
System.out.println(Thread.currentThread().getName() + ",耗时:" + (endTime - startTime));
}
}
输出:
余票数817
main,耗时:2842
ok,问题解决,但锁无疑是牺牲了性能的,从我们的代码看,原先的执行是所有线程并行执行,而加了锁之后,就变成了多个线程线性执行,并行变成了串行
在日常开发和很多的JDK源码中都能看到锁的身影,出现了锁,一定会有多个线程操作一个资源的情况,而锁只允许线程们排着队处理这些资源,每次处理完了就把自己的副本同步到主存,然后其它线程获取主存数据,再操作,再同步,依次循环。
加锁导致了线程串行执行,这与我们开启多线程的初衷看起来似乎是相悖的,无疑会减低代码的执行效率。
借助锁,我们实现了线程安全,但是锁会降低性能,我们有没有什么别的方案,不加锁就能实现线程安全呢?
CAS
于是引出另一个主角:CAS(CompareAndSwap),直接翻译就是对比交换
package com.example.entity;
import sun.misc.Unsafe;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class CasDemo {
private static AtomicInteger totalTickets = new AtomicInteger(10000);
public static void buy() throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(5);
totalTickets.getAndDecrement();
}
public static void main(String[] args) throws InterruptedException {
long startTime = System.currentTimeMillis();
CountDownLatch countDownLatch = new CountDownLatch(20);
int threadSize = 20;
for(int i = 0; i < threadSize; i++){
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
for(int i = 0; i < 500; i++){
buy();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
}
});
thread.start();
}
countDownLatch.await();
System.out.println("余票数" + totalTickets);
long endTime = System.currentTimeMillis();
System.out.println(Thread.currentThread().getName() + ",耗时:" + (endTime - startTime));
}
}
输出:
余票数0
main,耗时:2871
这里的效率看起来没有比加上悲观锁(synchronized )高出多少,博主水平有限目前还不知道原因,但可以肯定的是,CAS锁确实没有加悲观锁,参考https://blog.csdn.net/qq_35190492/article/details/104691668(包含数据库乐观锁的应用)
和https://blog.csdn.net/qqxm1/article/details/93244571
它在线程操作变量然后写回主存之前做了一个限制,在操作玩变量值变更之后,将数据写回主存之前,会先从主存获取一次变量副本并与自己持有的副本做比较,两者相同说明在此期间没有别的线程修改主存数据,则将自己的数据写入主存,如果不同则说明此期间有线程修改了主存数据,则此次写回操作失效,失效后当前线程可以继续尝试或者认为自己执行失败,也可以放弃执行。
数据库的乐观锁同样如此,我们在操作DB之前持有乐观态度认为不会有人修改我们的数据,在写DB之前先查一下,发现数据变了说明有人修改,则放弃我们的修改,如果没变则符合预期,正常操作数据库。
CAS是基于CPU指令的,同一批线程一起执行CAS只有一个线程能成功,会不会出现一个线程CAS成功,写回主存之前切换到另一个线程执行并导致数据变化呢?答案是不会,因为CAS属于系统源语,属于操作系统范畴,由若干指令组成用于完成某个功能(记得这段话在大学的操作系统书本上出现过),源语不可中断,所以不会出现上面的问题
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;
}
从源码也不难看出来,在一定条件下,会陷入类似死循环的逻辑(线程不满足CAS条件一直重试),这也是CAS的缺陷
java中的CAS
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
参数var1:表示要操作的对象。
参数var2:表示要操作对象中属性地址的偏移量。
参数var4:表示需要修改数据的期望的值。
参数var5:表示需要修改为的新值。
Java中的CAS通过调用JNI的代码实现,JNI:java Native Interface,允许java调用其它语言。而compareAndSwapXXX系列的方法就是借助C语言来调用cpu底层指令实现的。
CAS存在的问题-ABA
假设我们主存中的变量值是A,线程副本数据此时也是A,然后线程做了些其它操作,把变量值更新成了B,但是在CAS之前,另一个线程又做了些操作把变量变回了A,那么在CAS的时候就不会发现变量值的变化,但实际上它确实改变过。虽然队最终结果无影响,但是变量确实变化了
ABA问题的处理
版本号机制,类似于分布式下保持一致性的机制,没修改一次变量就变更一次版本号,在CAS之前就先check版本号,如果版本号不符合要求就禁止CAS操作,如果变量在CAS之前被多次修改过,则禁止CAS操作