【多线程】深入剖析线程安全问题

💐个人主页:初晴~

📚相关专栏:多线程 / javaEE初阶


前言

线程安全问题是在多线程学习中一个十分重要的话题。多个线程并发执行就容易产生许多冲突与问题,如何协调好每个线程的执行,让多线程编程"多而不乱",就是线程安全问题学习所要实现的了。这篇文章就让我们来深入探讨线程安全吧

目录

前言

一、概念

二、Synchronized

1、修改共享数据问题

2、解决方法

3、synchronized使用

(1)修饰代码块

(2)修饰方法

4、synchronized特性

(1)互斥

(2)可重入

三、死锁

1、循环依赖

2、哲学家问题

四、volatile

1、内存可见性

2、volatile


一、概念

想给出⼀个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运⾏的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

造成线程不安全的主要原因是线程调度是随机的,这是线程安全问题的罪魁祸⾸,随机调度使⼀个程序在多线程环境下, 执⾏顺序存在很多的变数. 程序猿必须保证 在任意执⾏顺序下 , 代码都能正常⼯作.

二、Synchronized

1、修改共享数据问题

让我们先来看一下下面这段代码:

java 复制代码
public class Main {
    public static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for(int i=0;i<50000;i++){
                count++;
            }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<50000;i++){
                count++;
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

如果是在单线程的环境下执行类似逻辑,那结果毫无疑问肯定是100000,但是实际结果却相距甚远:

甚至每次运行的结果都不一样:


上⾯的线程不安全的代码中, 涉及到多个线程针对 count 变量进⾏修改. 此时这个 count 是⼀个多个线程都能访问到的 "共享数据"。多线程在同时修改同一个数据时就容易出现问题。
主要是由于修改操作看似是一个操作,实际上操作系统要执行多个指令。比如在执行count++操作时就有三步操作:

1、load 把内存中的数据读取到CPU寄存器中
2、add 把cpu寄存器里的数据+1
3、save 把CPU寄存器里的值写回内存

而CPU在调度执行线程时,随时都有可能切换执行其它线程(抢占式执行,随机调度)

指令 是CPU执行的最基本单位,要切换线程,也会等当前线程的指令执行完毕才调走,不会出现指令执行一半的情况。

但由于count++操作需要三个指令,CPU执行了一个指令或两个指令或三个指令的任何时候都有可能被调度走从而使此次count++操作的结果产生偏差,并且由于调度的随机性,这种偏差也是无法预测的,也就导致了我们上面所看到的每次程序执行结果都不同的现象了

还有很多可能得情况,博主就不一一例举了。但只有第一二种情况程序才能正常运行:

这样两个线程的三个指令都能分别完整的被执行完,最后结果就会是count=2了。

错误的情况会类似下面这种:

上述过程中,明明执行了++操作两次,但最终结果却是1。因为这两次加的过程中结果出现了覆盖。

由于五万次循环中,无法确定有多少次的执行顺序是1、2这两种正确的执行顺序,因此最终的结果是不确定的,而这个值是一定小于10万的。

2、解决方法

会出现上述问题的主要原因是java中许多语句的执行都不是原子性的,这导致了如果⼀个线程正在对⼀个变量操作,中途其他线程插⼊进来了,如果这个操作被打断了,结果就可能是错误的。

比如说我们把⼀段代码想象成⼀个房间,每个线程就是要进⼊这个房间的⼈。如果没有任何机制保证,A进⼊房间之后,还没有出来;B 是不是也可以进⼊房间,打断 A 在房间⾥的隐私。这个就是不具备原⼦性的。

那我们应该如何解决这个问题呢?是不是只要给房间加⼀把锁,A 进去就把⻔锁上,其他⼈是不是就进不来了。这样就保证了这段代码的原⼦性了。

同样的,我们也可以给线程上一把"锁",把"非原子"的操作变成"原子",保证线程执行的原子性。在java中,我们就可以用synchronized实现该操作

3、synchronized使用

(1)修饰代码块

用sychronized修饰代码块{},进入{就会自动加锁,出了}就会解锁,如下:


这时我们发现程序报错了。是由于()中需要指定一个锁对象,这个锁对象可以是任何对象,重点是通过锁对象的比较来确定两个线程是否是否要上同一把锁,如果锁对象一致,就会产生锁竞争,只有一个线程执行完毕解锁后,其它竞争线程才能拿到锁执行操作。因此我们应该为这两个线程准备一个锁对象:

java 复制代码
public class Main {
    public static int count=0;
    public static Object locker=new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for(int i=0;i<50000;i++){
                synchronized (locker){
                    count++;
                }
            }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<50000;i++){   
                synchronized (locker){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

这下程序结果就没问题了:


运行流程图:

本质上就是把随机的并发执行过程,强制变成了串行,从而解决了该线程安全问题

注意:

  • 此处的加锁,并不是 真的让count++变成原子 的,也没有干预线程的调度 ,只不过是通过这种加锁的方式,使一个线程在执行count++的过程中,其它的线程的count++不能插队进来
  • synchronized是关键字 ,不是方法,()中的并不是参数 ,需要指定一个**"锁对象"**,可以是任何对象,来进行后续判定
  • 锁对象的作用就是来区分多个线程是否针对**"同一个对象"** 加锁,是通过同一个对象加锁,就会出现阻塞等待 (锁竞争/锁冲突),若不是则不会出现"阻塞",线程间任然是随机调度的并发执行
    我们重点要理解,synchronized 锁的是什么. 两个线程竞争同⼀把锁, 才会产⽣阻塞等待.
    两个线程分别尝试获取两把不同的锁, 不会产⽣竞争

(2)修饰方法

synchronized不仅可以修饰代码块,还能修饰方法:

java 复制代码
class Method{
    public static int count=0;
    public synchronized void add(){
        //等价于synchronized (this){}
        count++;
    }
    //修饰静态方法
//    public static synchronized void add2(){
//        //等价于synchronized (Method.class){}
//        count++;
//    }

}
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Method method=new Method();
        Thread t1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                method.add();
            }
        });
        Thread t2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                method.add();
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(Method.count);
    }
}

synchronized用的锁存在java对象头里面的,在一个java对象中,除了自己定义的属性和方法,还有一些自带的属性,这些自带的属性就称为对象头,其中就有属性表示当前对象是否加锁。

注意

并非加了sychronized就一定线程安全,还是看具体代码的执行。是否要synchronized,如何加锁都是与应用场景直接相关的。锁在需要的时候才使用,不需要的时候尽量不要"无脑加锁",因为上锁是需要消耗资源的,无意义的上锁反而会降低运行效率

并且,使用锁就有可能触发阻塞 ,阻塞的时长,何时可以恢复执行都是不可控的,"无脑加锁"反而可能引起其它问题

因此,实际开发中一般都是结合具体场景自行加锁 ,很少会直接用synchronized修饰方法。像java中自带的StringBuffer、Vector、Hashtable都不推荐使用,甚至jdk在未来版本中还可能直接把他们优化掉


4、synchronized特性

(1)互斥

synchronized 会起到互斥效果, 某个线程执⾏到某个对象的 synchronized 中时, 其他线程如果也执⾏到同⼀个对象 synchronized 就会阻塞等待.
• 进⼊ synchronized 修饰的代码块, 相当于 加锁
• 退出 synchronized 修饰的代码块, 相当于 解锁


synchronized⽤的锁是存在Java对象头⾥的。

可以粗略理解成, 每个对象在内存中存储的时候, 都存有⼀块内存表⽰当前的 "锁定" 状态(类似于厕所的 "有⼈/⽆⼈").
如果当前是 "⽆⼈" 状态, 那么就可以使⽤, 使⽤时需要设为 "有⼈" 状态.
如果当前是 "有⼈" 状态, 那么其他⼈⽆法使⽤, 只能排队


理解 "阻塞等待".
针对每⼀把锁, 操作系统内部都维护了⼀个等待队列 . 当这个锁被某个线程占有的时候, 其他线程尝试进⾏加锁, 就加不上了, 就会阻塞等待 , ⼀直等到之前的线程解锁之后, 由操作系统唤醒⼀个新的线程, 再来获取到这个锁.

注意:
• 上⼀个线程解锁之后, 下⼀个线程并不是⽴即就能获取到锁. ⽽是要靠操作系统"唤醒". 这也就是操作系统线程调度的⼀部分⼯作.
• 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B ⽐ C 先来的, 但是 B 不⼀定就能获取到锁, ⽽是和 C 重新竞争, 并不遵守先来后到的规则.
synchronized的底层是使⽤操作系统的mutex lock实现的

(2)可重入

synchronized 同步块对同⼀条线程来说是可重⼊的,不会出现⾃⼰把⾃⼰锁死的问题;
理解 "把⾃⼰锁死":

java 复制代码
class Counter{
    public static int count=0;
    public void add(){
        synchronized (this){
            count++;
        }
    }
}
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter=new Counter();
        Thread t1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (counter){
                    counter.add();
                }
            }
        });
        t1.start();
        t1.join();
        
        System.out.println(counter.count);
    }
}

这时我们发现:

(1)里面的synchronized想要拿到锁,就需要外面的synchronized释放锁

(2)外面的synchronized想要释放锁,就需要执行到"}"

(3)想要执行到"}"就需要执行完add()方法

(4)但此时add()方法正处于阻塞等待中

这就导致了代码执行陷入死循环,程序会不断阻塞下去
这样的锁称为 不可重⼊锁
Java 中的 synchronized 是 可重⼊锁, 因此没有上⾯的问题.
在可重⼊锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息.

  • 可重入锁加锁前是要判断当前这个锁是否是被占用的状态
  • 加锁时会在锁中额外记录当前是哪个线程对这个锁加锁了,即记录"线程持有者"
  • 如果发现加锁线程就是当前锁的持有者 ,并不会 真正地进行加锁 操作,也不会进行任何阻塞操作,而是直接放行,往下继续执行代码,同时锁内的计数器值会加一
  • 每当执行到一次"}"时,即多重锁中某一个锁要"解锁"时,则计数器值减一 ,判断当前计数器值是否为 0 ,若不是,继续执行后续代码;若是,才真正释放锁(才能被别的线程获取到)

三、死锁

出现死锁的第一种情况就是上述的,一个线程针对一把锁连续加锁两次的情况,这是不可重入锁会出现的问题,不过java中的synchronized是可重入锁,因此这种情况就不必讨论了。

1、循环依赖

我们先来例举一个场景:

公司规定要进入办公楼需向保安出示工牌,但公司高管a把工牌落在公司内了, a要要去办公楼工作,却被保安因没有工牌而拦下了,a说他得进去才能拿到工牌,保安则说没有工牌就不能进去,如果二者互不相让,就会一直僵持在这。

当两个人的工作互相依赖于对方工作的完成才能完成的话,就会陷入这种死循环的局面

多线程中也是这样:

有两个线程1和2,两把锁A和B,线程1先针对A加锁,线程2针对B加锁,线程1在不释放锁A的情况下再针对B加锁,同时线程2在不释放B的情况下针对A加锁,双方都得等到对方占有的锁解开才能解开自己的锁,就会导致两个线程一直处于"阻塞"状态

java 复制代码
public class Main {
    private static Object locker1=new Object();
    private static Object locker2=new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            synchronized (locker1){
                System.out.println("t1 加锁 locker1 完成");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2){
                    System.out.println("t1 加锁 locker2 完成");
                }
            }
        });
        Thread t2=new Thread(()->{
            synchronized (locker2){
                System.out.println("t2 加锁 locker2 完成");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker1){
                    System.out.println("t2 加锁 locker1 完成");
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("主线程运行完毕");
    }
}

执行结果

通过jconsole可以直接观测到两个线程的状态:

2、哲学家问题

题目描述:

有五个哲学家围坐在一个圆桌旁,每个人面前有一盘面,每两个人之间放了一根筷子,所有的哲学家都只会在思考和进餐两种行为间交替。哲学家只有同时拿到左边和右边的筷子才能吃到面,而同一根筷子在同一时间只能被一个哲学家使用。每个哲学家吃完面后都需要把筷子放回桌面以供其他哲学家吃面。只要条件允许,哲学家可以拿起左边或者右边的筷子,但在没有同时拿到左右筷子时不能进食

通常情况下,这个模型是可以正常运转的,但一旦出现极端情况,就会出现死锁。

例如五位哲学家同时拿起了左手边的筷子 ,他们的右手都没有筷子可拿,且哲学家非常固执,吃不到面就绝对不会放下左手的筷子。这就导致了每个哲学家手上都有且仅有一根筷子 ,而只有一根筷子又无法完成吃面操作,就会等待其它哲学家吃完面后放下的筷子,而每个哲学家都没法吃面也不会放下筷子,就导致所有的哲学家都会一直陷入"阻塞等待"的状态,也就出现了"死锁"

这时我们就需要去研究一下出现死锁的必要条件了:

1、锁是互斥的【锁的基本特性】

2、锁是不可被抢占的,线程1拿到了锁A,如果线程1不主动释放A,线程2不能把A抢过来

3、请求和保持。线程1 拿到锁 A 后,在不释放 A 的情况下,去拿锁 B

4、循环等待/环路等待/循环依赖,多个线程获取锁的过程存在循环等待

1和2是由synchronized锁的基本特性导致的,程序员无法去干预,所以一般不从这两个方面去试图解决死锁。

针对3、4,如果必须按照请求和保持的方式,获取N个锁,该如何避免出现循环等待呢?

我们可以给每个锁编号1,2,3......,约定所有线程在加锁的时候,必须按照一定的顺序来加锁,比如必须先针对编号小的锁加锁,再对编号大的锁加锁:

如上图,哲学家5吃完面后就能放下4、5两根筷子,哲学家4就能拿起筷子4,就能用筷子3、4吃上面,吃完后又会放下筷子3、4,让哲学家3也能吃上面,依次类推......这样,每个哲学家就都能吃上面,不会出现 一开始的"循环等待"了

也就是说,我们可以这样改造之前的代码,让t1和t2都按照先加小锁再加大锁的顺序加锁,就不会出现死锁了:

java 复制代码
public class Main {
    private static Object locker1=new Object();
    private static Object locker2=new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            synchronized (locker1){
                System.out.println("t1 加锁 locker1 完成");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2){
                    System.out.println("t1 加锁 locker2 完成");
                }
            }
        });
        Thread t2=new Thread(()->{
            synchronized (locker1){
                System.out.println("t2 加锁 locker1 完成");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2){
                    System.out.println("t2 加锁 locker2 完成");
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("主线程运行完毕");
    }
}

只要遵守一定的加锁的顺序,无论接下来该模型的运行顺序如何,无论出现多么极端的情况,都不会再出现"死锁"了


四、volatile

1、内存可见性

我们先来看一下下面这段代码:

java 复制代码
public class Main {
    public static int n=0;

    public static void main(String[] args) {
        Thread t1=new Thread(()->{
            while (n==0){
                //什么都不写
            }
            System.out.println("t1 线程结束循环");
        });
        Thread t2=new Thread(()->{
            Scanner in=new Scanner(System.in);
            System.out.println("请输入一个整数:");
            n=in.nextInt();
        });
        t1.start();
        t2.start();
    }
}

按道理来说在我们输入一个非 0 整数后,n的值就不为0了,t1线程中的while(n==0)的判断就为假,跳出while循环,然后输出"t1 线程结束循环"语句,但实际运行却不是这样:

可以看到,在输入1后,t1并没有像我们预想的那样输出任何语句,通过jconsole我们也可以看到t1线程(Thread-0)任然在持续地工作:

很明显实际运行结果与我们预期的结果并不相符,这就是出现"bug"了,同样也是线程安全问题。

那么为什么会有内存可见性问题呢?让我们先计算机数据存储的构成:

而问题就出在t1线程中的这段代码:

while循环会循环非常多次,每次循环,都要执行一次"n==0"的判定,这次情况下实现这个判定需要两个操作:

(1)从内存中读取数据到寄存器中

(读取内存,这个操作相对而言速度非常慢)

(2)通过类似于cmp的指令,比较寄存器0的值

(这个指令的执行速度相对就非常快了)

此时,JVM在执行这个代码的时候发现,每次循环的过程中,执行(1)操作的开销非常大,并且在许多次的执行中,结果都是一样的。这时,JVM就可能没有意识到用户未来可能会修改n值,于是便直接大胆优化掉 (1)操作,这样后续每次操作就不会 出现读取内存 中的数据了,而是直接读取寄存器/cache中的数据(缓存的结果)了。

这样,在后续运行时循环的开销确实大幅降低了。但是,一旦当用户修改n的值时,内存中的n值会发生改变,但寄存器中的n值没有变化,又由于此时循环不会真的读取内存 ,也就感知不到n的变化。

这时,内存中的n值的变化,对于t1来说就是"不可见的"。这就导致bug的产生,也就是"内存可见性问题"了

那么,编译器为何要做出这种优化呢?

主要是由于一些程序员写出的代码过于低效,为了降低程序员的门槛,即使代码水平一般,最终的运行速度也不至于太低,因此,主流编译器都会引入优化机制

优化编译器会自动调整你的代码,保持在原有逻辑不变 的前提下,提高代码的执行效率。在一般情况下,代码优化的效果是非常好的。但是,编译器的优化是一个非常复杂的问题,某个代码,何时优化 ,优化到什么程度 都是不好确定的。对于程序员来说,很难确定某个代码是否会优化,并且代码稍微变化一点,优化结果可能都截然不同,比如当我们稍微修改一下t1线程的代码:

此处即使sleep的时间非常短,刚才的内存可见性问题就消失了,此时t2对于n的修改,t1就可以感知到了,运行结果也就正常了:

这说明加入sleep后,刚才谈到的针对读取n内存数据优化 的操作就不再进行了,因为和读取内存操作相比,sleep的开销是更大 的,远远超过了读取内存的开销。此时再对内存读取做优化对效率的提升就微乎其微 了,编译器也就不会优化了。

如果这时候,循环里并没有sleep,我们又希望代码能够无bug地正常运行,这是我们就可以利用"volatile"关键字了。

2、volatile

volatile 修饰的变量,会提示编译器:这个变量是"易变"的。编译器做出上述优化的前提是,编译器认为针对某个变量的频繁读取 ,结果都是固定的。而"易变"的就是告诉编译器该变量值未来是可能会发生变化 的,此时编译器就会禁止上述优化,保证每次循环都是从内存中重新获取的数据, 从而不会出现 "内存可⻅性"问题了。

代码在写⼊ volatile 修饰的变量的时候,

  • 编译器生成代码时,会在变量读取操作附件生成一些特殊指令,称为"内存屏障",后续JVM执行到这些特殊指令,就不会进行上述优化了
  • 改变线程⼯作内存中volatile变量副本的值
  • 将改变后的副本的值从⼯作内存刷新到主内存
    代码在读取 volatile 修饰的变量的时候,
  • 从主内存中读取volatile变量的最新值到线程的⼯作内存中
  • 从⼯作内存中读取volatile变量的副本
    前⾯我们讨论内存可⻅性时说了, 直接访问⼯作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度⾮常快, 但是可能出现数据不⼀致的情况.
    加上 volatile , 强制读写内存. 速度是 了, 但是数据变的更准确了.
    因此只需要用volatile修饰变量n,告诉编译器n是"易变"的,这样程序就能正常执行了
java 复制代码
public class Main {
    public volatile static int n=0;

    public static void main(String[] args) {
        Thread t1=new Thread(()->{
            while (n==0){
                //什么都不写
            }
            System.out.println("t1 线程结束循环");
        });
        Thread t2=new Thread(()->{
            Scanner in=new Scanner(System.in);
            System.out.println("请输入一个整数:");
            n=in.nextInt();
        });
        t1.start();
        t2.start();
    }
}

编译器的开发者知道在某些场景下,优化可能会出现bug,于是就通过"volatile"这类关键字把权限交给了程序员,让程序员可以部分干预优化 的进行,把优化权交给程序员,从而尽可能减少类似"内存可见性"的问题

注意:

volatile 不保证原⼦性

如果多个线程对同一个变量执行修改操作(count++),volatile也无能为力。该加锁还是要加锁


那么本篇文章就到此为止了,如果觉得这篇文章对你有帮助的话,可以点一下关注和点赞来支持作者哦。作者还是一个萌新,如果有什么讲的不对的地方欢迎在评论区指出,希望能够和你们一起进步✊

相关推荐
Oneforlove_twoforjob9 分钟前
【Java基础面试题033】Java泛型的作用是什么?
java·开发语言
向宇it26 分钟前
【从零开始入门unity游戏开发之——C#篇24】C#面向对象继承——万物之父(object)、装箱和拆箱、sealed 密封类
java·开发语言·unity·c#·游戏引擎
小蜗牛慢慢爬行28 分钟前
Hibernate、JPA、Spring DATA JPA、Hibernate 代理和架构
java·架构·hibernate
星河梦瑾1 小时前
SpringBoot相关漏洞学习资料
java·经验分享·spring boot·安全
黄名富1 小时前
Redis 附加功能(二)— 自动过期、流水线与事务及Lua脚本
java·数据库·redis·lua
love静思冥想1 小时前
JMeter 使用详解
java·jmeter
言、雲1 小时前
从tryLock()源码来出发,解析Redisson的重试机制和看门狗机制
java·开发语言·数据库
TT哇2 小时前
【数据结构练习题】链表与LinkedList
java·数据结构·链表
Yvemil72 小时前
《开启微服务之旅:Spring Boot 从入门到实践》(三)
java
Anna。。2 小时前
Java入门2-idea 第五章:IO流(java.io包中)
java·开发语言·intellij-idea