目录
在多线程中,最最复杂的地方就是线程安全,当然它也是最重要的地方。
1. 什么是线程安全问题?
什么是线程安全问题呢?
举个简单的例子来说:
某些代码,在单个线程中运行,执行结果完全正确。但是,如果将一个操作,在多个线程中运行,就可能会出现 bug 。这就是 “ 线程安全问题 ” ,也就是 “ 线程不安全 ” 。
就举个简单点的例子,我们在两个线程中各自自增 1w 次,那当我们在 main 方法中打印就应该是 2w ,事实果真如此吗,请看VCR:
那为什么会出现上述问题呢?
其实,count++ 这步操作 站在 cpu 的角度上本质是分成三步进行的,先将数据从内存读取到 cpu 寄存器中,在将寄存器中的数据 ++ ,最后在保存到内存中去。
由于 线程之间的调度是随机的,所以,cpu 完全有可能出现上图情况
按照上图,当我们两个线程进行如图的操作时,虽然线程 t1 和线程 t2 个自增了一次,可是存入内存中的结果只自增了一次,这就导致出现了 bug ,也使得 “ 线程不安全 ” 。
2. 产生线程安全问题的原因
1. 在操作系统中,由于线程的调度顺序是随机的(抢占式执行)
这个是 线程的属性。
2. 多个线程,针对同一个变量进行修改。 就如同上面的两线程对 count 进行值的修改。
这个是我们的需求。
3. 由于你在线程中执行的操作,不是原子的。
什么叫做不是原子的呢?
就拿上面 count 来说吧,当 count 进行 ++ 的时候, cpu 会先取值,加加,存值,它将 count++ 分成了三个步骤,所以就不是原子的。
4. 内存可见性问题
这是由于编译器优化处理 而出现的问题。
5. 指令重排序问题(后面单例模式中会说明)
3. 如何解决线程安全问题
要想解决线程安全问题,就得从线程安全问题产生的源头出发:
1. 线程调度随机
这个是属于线程的基本属性,我们无法进行更改。
2. 多线程对一变量修改
这个属于我们的需求,我们不希望修改这个。
3. 操作不原子
针对这个原子性,我们可以对其加锁,将不原子的操作锁起来,变成原子的,这样就不会影响线程安全了,那是通过什么实现的呢,请看下图:
当我们通过 synchronized 对 Object类型的 locker 进行加锁之后,多线程对一个变量修改,也就不会出现 bug 了。这里的原理是什么呢:当我们在 t1 线程中对 locker 进行加锁之后,当 t2 在想对locker 进行加锁的时候,就会产生 “ 锁冲突 / 锁竞争 ”,t2 在获取到 锁 locker 之前就会阻塞等待。
这里加锁的对象不重要,主要是用来判断 是否竞争的同一个锁 会不会出现“ 锁冲突 / 锁竞争 ”
如果我们对不同的 对象进行加锁,这两个线程就 不会出现 “ 锁冲突 / 锁竞争 ” ,就导致这个锁加了就跟没加一样,两个线程不会因为 锁 而等待,就可能会出现 bug。
代码:
public class Main {
static int count=0;
static Object locker=new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
for(int i=0;i<10000;i++){
synchronized (locker) {
count++;
}
}
});
Thread t2=new Thread(()->{
for(int i=0;i<10000;i++){
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
// 等待两线程执行完成
t1.join();
t2.join();
System.out.println(count);
}
在加锁解决 “ 线程不安全问题 ” 之后,还得注意锁的使用,当我们在使用锁的时候,难免会出现 “ 锁嵌套 ” ,而 “ 锁嵌套 ” 就可能会导致 “ 死锁 ” 的情况:
那当我们写出上述代码( 锁嵌套 )的时候,为什么最终还是会输出所有的结果呢?
这是由于 cpu 处理速度特别快,就导致,t1 线程创建完调用 start( ) 方法之后,很快就对 locker 上锁了,在 t2 还没来得及对 locker2 进行加锁的时候,t1 已经对 locker2 进行加锁了,并且执行完打印操作之后,快速释放 两个锁,这就导致 虽然 锁嵌套 了,但是还是能够输出正确的 结果。
所以,我们可以在 获取第一把锁之后,sleep( ) 一秒,再来看结果就会发现 线程都没有结束,并且,都没有打印出东西:
那针对上述 “ 死锁 ” 的情况,我们该如何去进行代码的修改来避免这个问题呢?
我们可以针对 “ 死锁 ” 的形成条件入手:
1. 互斥使用 : 当我们对某个对象进行上锁之后,解锁之前,其他线程不能对这个锁进行上锁操作,如果要使用,就只能阻塞等待。
这是属于 锁 的基本属性,我们无法干预。
2. 不可抢占 :当锁已经被某个线程拿到之后,除非这个线程主动释放,其他线程才能使用,不能够强行抢过来。
这还是属于 锁 的基本特性。
3. 请求保持 : 当一个线程尝试获取多把锁,先拿到 锁1 之后,再去尝试获取 锁2 ,这个过程中 锁1 不会释放。
针对这个,我们可以调整代码结构,避免编写 “ 锁嵌套 ” 的逻辑。但是,在编写大量代码的时候,我们难免会不经意的编写到 嵌套结构:
4. 等待循环 / 环路等待 : 等待的关系构成 环了。
为了避免成环,我们可以约定对锁进行编号,然后,每次优先对编号小的锁进行加锁操作,此时,循环就破解了。
拓展:
“ 哲学家就餐问题 ” :
怎么解决呢:
4. 内存不可见性
计算机运行的程序以及代码,要经常访问数据,而这些依赖的数据,往往会存在内存中,当 cpu 想要使用这个数据的时候,它就会线先内存中读,再放到 cpu 寄存器中,最后参与运算。
由于 cpu 读取内存中的数据这个操作 其实非常慢,为解决这个问题,提高编译的效率,此时编译器就会对代码做一些优化,把一些本来要从内存中读取的操作,改成从 cpu 寄存器中读 ,这样减少了读取内存的次数,就可以提高整体的程序效率了。
上述图中,我们在 线程t1 中执行死循环,判断条件为 isQuite == 0 ,由于 cpu 执行速率非常快,此时,编译器就发现,经历这么多次读内存,isQuite 还是没有变,此时 isQuite 就会被 cpu 认为并不改变,此时编译器就会优化代码,直接从 cpu 寄存器中读取,导致后面我们修改 isQuite 的值,线程t1 并没有从内存中读取 修改过的 isQuite ,这样 线程 t1 就陷入死循环了。
那如何解决这个问题呢?
我们只需要对需要操作的 isQuite 用 volatile 修饰,就相当于告诉 编译器,你不要优化读取 isQuite 的操作。
下面这个代码,没有加 volatile 进行修饰,为啥还是能让 t1线程 结束呢?
这是由于当我们加了 sleep 休眠之后,cpu 读取内存的操作就慢下来了,这就导致读内存的开销就大幅度减小了,就不会触发编译器对读内存操作的优化了,此时修改 isQuite 就会让t1停下来。