(一).线程安全

一段代码,如果在多线程并发执行的情况下,出现bug,就称为“线程不安全”,反之,如果没有bug,就是“线程安全”

(二).引入

    private static int count=0;
    public static void main(String[] args) throws InterruptedException {

        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        thread1.start();
        thread2.start();


        System.out.println(count);
    }

我们的预期结果count=100000,因为两个线程中分别对count++了50000次,所以一共就是100000次,但当运行的结果为0,我们就能意识到,在main线程中先执行了System.out.println(count);  这是因为线程是并发执行的,调度是随机的,当thread1 和 thread2开始执行的时候,已经打印完了所以输出0

那么我们可以通过 join()方法,在main线程中分别执行 thread1.join() 和 thread2.join(),意味着main线程要等到 thread1 和 thread2 线程都结束之后 main线程才能结束

    private static int count=0;
    public static void main(String[] args) throws InterruptedException {

        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(count);
    }

此时就会出现两种情况

1.thread1先结束,thread2后结束

        main线程先在thread1.join()中阻塞等待,thread1结束,main线程再在 thread2.join()中阻塞等待,thread2结束,main线程继续执行,然后打印,最终打印的值就是thread1和thread2都执行完的值

2.thread2先结束,thread1后结束

        main线程先在thread1.join()中阻塞等待,thread2结束,main线程在thread1.join()中继续阻塞等待,thread1结束,thread.join()继续执行,main执行到 thread2.join(),由于thread2 已经结束了,此时thread2.join()是不会阻塞的,main线程继续执行后续的打印,最终打印的的就是thread1和thread2都执行完的值

当程序运行起来的时候,发现,并不是我们要的100000

而且每次的值还都不一样,很明显,是有bug的

那么我们应该如何修改这个问题?

    private static int count=0;
    public static void main(String[] args) throws InterruptedException {

        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        thread1.start();
        thread1.join();
        thread2.start();
        thread2.join();

        System.out.println(count);
    }

当我们将两个join()方法分开,发现问题就解决了

我们这样修改的代码,就是让thread2线程一直等到thread1线程执行完成之后才开始执行,也就是串行化执行,这样就确保了程序的正确性

这样的问题,就是由多线程并发执行引起的问题,对于是由多线程并发执行代码引起的bug,我们就称为 “线程安全问题”或者叫做“线程不安全”,反之如果一个代码,在多线程并发执行的环境下,没有出现类似于上述的bug,此时这样的代码就称为“线程安全”

那么为什么会出现这种情况?

这里,我们需要站在cpu执行指令的角度去思考

count++这个操作,看起来只是一段简单的代码,实际上对应到3个cpu指令

①.load,将 内存中的count值读取到cpu寄存器

②.add,把指定的寄存器中的值进行 +1操作,这时结果仍然还是在cpu寄存器中

③.save,把cpu寄存器中的值写回到内存中

由于是三个线程,thread1,thread2,main,所以cpu在执行这三条指令的过程中,线程的调度是随时切换的,所以这时候就会出现多种情况

1,2,3  线程切走

1,2,线程切走······线程切回来  3

1,线程切走······线程切回来   2,3

1,线程切走······线程切回来   2,线程切走······线程切回来   3

由于操作系统的调度是随机的,所以在执行任何一个指令的过程中,都可能会触发上面的“线程切换”,所以导致了最终的结果错误

我们通过一个图来看

当然图中我只挑选了随机的几种,其实一共有无数种,因为实际上每个线程调度走,都有可能有其他的更多的线程,甚至是别的进程的线程占用cpu执行

下面就模拟几种情况

我们发现情况一是正确的

当执行这种情况的时候,就出错了,count应该计算出的结果为2,结果最终算的结果1

所以综上所述,如果两个线程分别都load到0,那么一定会少加一次,如果一个线程load到0,另一个线程load到1,那么结果才是正确的,即一个线程的load在另一个线程的save之后

如果是这种情况的话,算的值更小,4次自增,算的值为1

下面再看一个示例

    private static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50; i++) {
                count++;
            }
        });

        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50; i++) {
                count++;
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(count);
    }

我们发现,当把循环次数调成50的时候,不需要串行化也能正确的运行该程序,而50000次的时候会发生线程安全问题,这又是我为什么?

这是因为,50 和 50000 ,线程执行的时间长短不同,对于50次而言,很可能出现一种情况,就是执行thread2.start()之前,thread1就已经执行完了,等执行thread2.start()的时候,变成串行化执行了,但是也是有概率发生线程安全的问题的,只不过概率很小

(三).线程安全问题产生的原因

1.[根本原因] 操作系统对于线程的调度是随机的,抢占式的执行

2.多个线程同时修改一个变量

thread1和thread2都是针对的count这个成员变量进行的修改

如果是一个线程修改一个变量,则没有问题

如果多个线程,不是同时修改同一个变量,则没问题

如果多个线程修改不同的变量,不会出现中间结果相互覆盖的情况,则没问题

如果多个线程读取同一个变量,则没问题

3.修改操作,不是原子的

这里的“原子”和当时学习数据库事务中的“原子性”中的原子的意思是一样的,如果修改操作只是对应到一个cpu指令,就可以认为是 “原子” 的,这样cpu就不会出现 “一条指令执行一半”的情况,如果对应到多个cpu,就不是原子的

4.内存可见性问题,引起的线程不安全

这个留到后面再进行介绍

5.指令重排序,引起的线程不安全

这个也留到后面再进行介绍

(四).如何解决线程安全问题

1.[根本原因] 操作系统对于线程的调度是随机的,抢占式的执行

这个是操作系统的底层设定,我们修改不了

2.多个线程同时修改一个变量

这个问题和代码的结构直接相关,我们可以通过调整代码的结构,规避一些线程不安全的代码,但是这样的方法不太通用

Java中的String就是采取了“不可变”性来确保线程安全,String没有提供public的修改方法,所以我们无法进行修改,和 final无关,final是用来实现 “不可继承”的

3.修改操作,不是原子的

Java中解决线程安全问题的最主要的方案,就是“加锁”,通过“加锁”让不是原子的操作,打包成一个原子的操作

(1).锁

Ⅰ.概念

计算机中的 “锁” 和我们家里的“锁”同样的概念,互斥/排他

在计算机中,不允许暴力拆锁,只能阻塞等待

针对于这部分代码,我们就可以通过 “锁”,把不是原子的 count++给锁起来,在count++之前先加锁,然后进行count++,计算完毕之后,再解锁,这样其他的线程就没有办法插队了

注意:加锁操作,不是把线程锁死到cpu上,禁止这个线程调度走,而是禁止其他线程重新加这个锁,避免其他线程的操作在当前线程执行过程中插队

Ⅱ.关键字

加锁和解锁本身是操作系统提供的api,很多编程语言对这样的api进行了封装,大多数的封装风格都采用了两个函数

加锁  lock();

//执行保护起来的逻辑

解锁 unlock();

在java中,使用 synchronized 关键字,搭配代码块,来实现类似的效果

    synchronized (加锁的对象){  //加锁

        //执行保护起来的逻辑

    }//解锁

对于 ”加锁的对象“,在java中任何一个对象都可以用作 “锁”

Ⅲ.不同位置加锁的区别

我们通过一个代码来看

    private static int count=0;
    public static void main(String[] args) throws InterruptedException {

        Object locker=new Object();

        Thread thread1=new Thread(()->{
            synchronized (locker){
                for (int i = 0; i < 50000; i++) {
                        count++;
                }
            }
        });
        Thread thread2=new Thread(()->{
            synchronized (locker){
                for (int i = 0; i < 50000; i++) {
                    count++;
                }
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }

    public static void main1(String[] args) throws InterruptedException {

        Object locker=new Object();

        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }

对于main中的代码我们可以看到,我们加的锁是将整个for循环给包裹起来了

对于main1中的代码可以看到,我们加的锁只将count给包裹起来

虽然这两种代码最终的运行效果没有区别,但是二者在运行的时候,也是有不同的地方的

注意:当在“锁”种出现,break,return这样的语句时,Java的处理方式就是,一旦出了synchronized(){}的右花括号(}),那么就会自动解锁

Ⅳ.锁的变种写法

可以使用synchronized修饰方法


class Counter{
    private int count=0;
    //锁变种
    synchronized public void add(){
        synchronized (this){
            count++;
        }
    }


//    //锁优化
//    public void add(){
//        synchronized (this){
//            count++;
//        }
//    }

    public int getCount(){
        return count;
    }
}
public class demo15 {

    public static void main(String[] args) throws InterruptedException {
        Counter counter=new Counter();
        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(counter.getCount());
    }
}

通过上图代码的运行,可以看到能获取到正确的结果

使用synchronized修饰方法,相当于时针对this进行加锁

特殊情况:

如果静态方法,那么静态方法不存在this,这时候,我们加锁的时候,相当于针对的是类对象进行加锁

这两种加锁的方式等价,选其中一种即可

Ⅴ.监视器锁 monitor lock

JVM中的一个术语,使用锁的过程中抛出一些异常,可能会看到监视器锁这样的报错信息

Ⅵ.可重入

我们依旧是通过一个示例来看

示例
package Thread;

class Counter1{
    private int count=0;


    synchronized public void add(){
        synchronized (this){
            count++;
        }
    }

    public int getCount(){
        return count;
    }
}

public class demo16 {

    public static void main(String[] args) throws InterruptedException {
        Counter1 counter1=new Counter1();
        Object locker=new Object();
        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    counter1.add();
                }
            }
        });

        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    counter1.add();
                }
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(counter1.getCount());
    }
}

通过上面的代码我们可以看到,当thread1加第一个锁的时候,确实能加上锁,但是当调用add()方法的时候,发现在add()方法中,又加了一层锁,当运行到这的时候,需要阻塞等待,必须等到第一层锁被释放,第二层加锁的阻塞才会被解除,这时候就会出现矛盾

要想解除阻塞就必须往下执行,而要想往下执行,就必须等到第一次的锁被释放,此时这种现象就被称为 “死锁”

为了解决该 “死锁”问题,Java 的synchronized()方法就引入了可重入的概念

可重入:当某个线程针对一个锁加锁成功后,后面该线程再次针对这个锁进行加锁,不会触发阻塞,而是继续往下走,因为当前这把锁就是被这个线程持有,但是如果其他线程尝试加锁,则会正常阻塞

可重入锁的实现原理:让锁对象内部保存,当前是哪个线程持有的这把锁,后续有线程针对这个锁加锁的时候,对比一下,锁持有者线程是否和当前加锁的线程是同一个

针对加了多层锁的情况,JVM会先引入一个计数器,初始化成0,每次触发"{"的时候,计数器++;每次触发"}"的时候,计数器--,当计数器--为0的时候,就是真正需要解锁的时候

面试题:如何自己实现一个可重入锁?

①.在锁内部确定是哪个线程持有这把锁,后续每次加锁的时候都进行判定

②.通过计数器,记录当前加锁的次数,从而确定何时解锁

Ⅶ.死锁的情况
①.一个线程,一把锁,连续加锁两把

也就是上面的这种情况

②.两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁

示例

    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();
        Object locker2=new Object();
        Thread thread1=new Thread(()->{
           synchronized (locker1){
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }

               synchronized (locker2){
                   System.out.println("thread1 线程两把锁都获取到了");
               }
           }
        });

        Thread thread2=new Thread(()->{
           synchronized (locker2){
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }

               synchronized (locker1){
                   System.out.println("thread2 线程两把锁都获取到了");
               }
           }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }

通过上面的代码,可以看出 thread1线程拿到 locker1后尝试拿locker2,thread2线程拿到locker2后尝试拿locker1,两者互不相让,最终构成了 “死锁”

当代码运行起来之后,通过 “jconsole.exe”可执行文件查看线程的状态

发现,线程状态为BLOCKED,说明该线程因为竞争锁而阻塞了

注意:这里加sleep(1000)的原因是为了避免构不成死锁,因为如果不加sleep(1000),很有可能thread1一口气就把locker1和locker2都拿到了,这个时候thread2可能还没开动呢

③.N个线程,M把锁

有一个经典的模型,“哲学家就餐问题”

这五个“哲学家”只会有这两种情况,一种情况是 “思考人生”,另一种情况是 “吃面条(需要拿筷子)”,每个哲学家的左右手两边都各有一根筷子,这5根筷子就对应的是5把锁,哲学家相当于线程,所以每个哲学家只需要拿到两根筷子即可,对应的每个线程只需要拿到两把锁即可

那么有一种情况就会出现死锁问题,就是5个哲学家同时都想吃面条,5个哲学家同时去拿左手的筷子

那么现在每个哲学家都拿不到右手的筷子,所以任何一个哲学家都吃不了面条,同时每个哲学家都不愿意放下左手的筷子,而是等待,此时就会出现死锁

Ⅷ.死锁的构成
①.锁是“互斥”的。

一个线程拿到锁之后,另一个线程再尝试获取锁,必须要阻塞等待

②.锁是不可“抢占”的。

对于两个线程,线程1拿到锁后,线程2也尝试获取这个锁,线程2必须阻塞等待,而不是线程2直接把锁抢过来

③.请求和保持。

一个线程拿到锁1后,不释放锁1,然后继续获取锁2,此时就会构成死锁。

类似于“哲学家就餐”的问题,如果某个哲学家放下左手的筷子,然后另一个哲学家拿右手的筷子,那么这个哲学家就可以吃上面条了,等这个哲学家吃完面条之后放下左手和右手的筷子,然后其他哲学家就可以吃上面条了

所以说,在代码中,我们要避免的“嵌套”锁

    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();
        Object locker2=new Object();
        Thread thread1=new Thread(()->{
           synchronized (locker1){
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }

               synchronized (locker2){
                   System.out.println("thread1 线程两把锁都获取到了");
               }
           }
        });

        Thread thread2=new Thread(()->{
           synchronized (locker2){
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }

               synchronized (locker1){
                   System.out.println("thread2 线程两把锁都获取到了");
               }
           }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }

对于上面这个代码,就属于“嵌套”了锁,解决的办法就是将“嵌套锁”改成 “并列锁”

    public static void main(String[] args) {

        Object locker1=new Object();
        Object locker2=new Object();

        Thread thread1=new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            //将 “嵌套锁” 改成 “并列锁”
            synchronized (locker2){
                System.out.println("thread1获取到两把锁了");
            }
        });
        Thread thread2=new Thread(()->{
            synchronized (locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            //将 “嵌套锁” 改成 “并列锁”
            synchronized (locker1){
                System.out.println("thread2获取到两把锁了");
            }
        });

        thread1.start();
        thread2.start();
    }
④.循环等待。

多个线程多把锁之间的等待过程,构成了“循环”,A等待B,B等待A 或者是其他情况

解决方法:约定好加锁的顺序,就可以破除循环等待了,例如:每个线程加锁的时候,永远是先获取序号小的锁,后获取序号大的锁

    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();
        Object locker2=new Object();
        Thread thread1=new Thread(()->{
           synchronized (locker1){
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }

               synchronized (locker2){
                   System.out.println("thread1 线程两把锁都获取到了");
               }
           }
        });

        Thread thread2=new Thread(()->{
           synchronized (locker2){
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }

               synchronized (locker1){
                   System.out.println("thread2 线程两把锁都获取到了");
               }
           }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }

对于上面的这个代码,thread1线程先加的locker1锁,然后 后加的locker2锁,thread2线程先加的locker2锁,然后 后加的locker1锁,此时就会出现死锁,我们要约定好一个规则,先加序号小的锁,后加序号大的锁

    public static void main(String[] args) {
        Object locker1=new Object();
        Object locker2=new Object();

        Thread thread1=new Thread(()->{
           synchronized (locker1){
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }

               synchronized (locker2){
                   System.out.println("thread1 获取到两把锁了");
               }
           }
        });

        Thread thread2=new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (locker2){
                    System.out.println("thread2 获取到两把锁了");
                }
            }
        });

        thread1.start();
        thread2.start();
    }

总结:针对死锁构成的这四个原因,①和②是锁的基本性质,我们只需要打破③和④就可以避免死锁的形成

(五).Java标准库中的线程安全类

1.Vector

2.HashTable

3.ConcurrentHashMap

4.SrtingBuffer

对于1,2,4来说,虽然有synchronized,但是都不推荐使用,不是写了synchronized就是100%安全的,3是针对2进行的优化,后面再具体介绍

一旦代码中使用了锁,就意味着代码可能因为锁的竞争而发现阻塞,那么程序的执行效率就会大打折扣,一旦发生阻塞,那么就意味着线程会从cpu上调度走,具体什么时候能够回来就不好说了

对于String来说,虽然没有加锁,但是String没有提供修改String的方法,所以说线程是安全的

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐