线程安全 - JavaEE

目录

一、什么是线程安全

1. 概要

2. 观察线程不安全的代码

3. 不安全解析

二、线程不安全的原因

四、synchronized关键字 - 监视器锁monitor lock

1. synchronized使用方法

2. synchronized的特性

(1)互斥

(2)可重入

3. Java标准库中的线程安全类

五、死锁

1. 死锁的三个典型情况

2. 可重入不可重入(见上文)

3. 死锁的四个必要条件

4. 如何破除死锁

六、volatile关键字

1. volatile能保证内存可见性

2. volatile不保证原子性

七、wait和notify


一、什么是线程安全

1. 概要

多线程编程很容易给实际开发中带来一定的风险,这种风险的情形就是线程安全问题。实际上线程不安全的根本原因是多线程的抢占式执行所带来的随机性,它会导致代码的执行顺序出现更多的变数,代码执行顺序在单线程情况下的执行顺序是固定的,而多线程的代码执行顺序就可能有无数种情况,所以就需要保证在这无数种线程调度顺序的情况下,代码的执行结果都是正确的。只要有一种情况下,代码结果不正确,就都视为是有bug,线程就不安全。

2. 观察线程不安全的代码

class Counter {
    public int count = 0;

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

public class ThreadDemo13 {
    public static void main(String[] args) {
        Counter counter = new Counter();

        // 搞两个线程, 两个线程分别针对 counter 来 调用 5w 次的 add 方法
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        // 启动线程
        t1.start();
        t2.start();

        // 等待两个线程结束
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 打印最终的 count 值
        System.out.println("count = " + counter.count);
    }
}

多次运行结果: 

 

 

3. 不安全解析

在代码中我们的需求的两个线程各自自增5万次,一共自增10万次,但实际结果不是10万,而且每一次的执行结果都不一样,这种情况下就说明程序出了bug。

为什么程序会出现bug?

上述代码的add()中的count++操作本质上在CPU中的操作分为了三步指令:

1. load:先把内存中的值读取到CPU的寄存器中;

2. add:再把CPU寄存器里的数值进行+1运算;

3. save:最后把得到的结果写回到内存中.

如果是两个线程并发的执行count++,此时就相当于是两组的这三步指令来进行执行,那么这个时候不同的线程调度顺序就可能产生结果上的差异。因为两个线程add的都是count这个数据,在并发执行过程中就会可能造成load add save混乱执行,操作的count就会乱套,所以程序就会出现每次结果不一,出bug的情况。

二、线程不安全的原因

1. 根本原因

多线程抢占式执行,随机调度

2. 代码结构

一般情况下,一个线程修改一个变量,不会有问题;多个线程读取同一个变量,也不会有问题;多个线程修改多个不同的变量,也不会有问题。但当多个线程同时修改同一个变量就会出问题。(注意这里所做的操作)

3. 原子性

如果修改操作是原子的,那一般是不会有线程安全问题。如果是非原子的操作,那么出现问题的概率就会大大增加。像上面所介绍的count++可以拆分成load add save三个操作,就很可能会出现问题。

4. 内存可见性(见后文volatile关键字)

5. 指令重排序

以上的原因是线程不安全的五个典型的原因,但不是全部的原因。一个代码究竟是线程安全还是不安全,都得具体问题具体分析,难以一概而论。如果一个代码出现了上面的原因,也可能是线程安全的,如果没出现也可能是线程不安全的。但最终不变的原则是:多线程运行的代码只要不出bug就是安全的。

三、解决之前的线程不安全问题

从原子性入手解决线程安全问题:加锁

通过加锁把不是原子的转化成“原子”的,其本质是把并发执行变成了串行执行。

使用synchronized关键字进行加锁。

class Counter {
    public int count = 0;

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

四、synchronized关键字 - 监视器锁monitor lock

1. synchronized使用方法

(1)修饰方法

【1】修饰普通方法:锁对象为this

【2】修饰静态方法:锁对象为类对象

(2)修饰代码块:锁对象需要显式/手动指定锁对象。

我们需要理解清楚锁对象是哪个,也就是说,我们做加锁操作,需要明确对哪个对象加锁。

如果是两个线程针对同一个对象加锁,就会产生阻塞等待(锁竞争/锁冲突);

如果两个线程针对不同对象加锁,不会阻塞等待(不会锁竞争/锁冲突)。

总而言之,无论这个对象是什么样的对象,锁对象相同,就会产生锁竞争(产生阻塞等待),锁对象不同就不会产生锁竞争(不会阻塞等待)

2. synchronized的特性

(1)互斥

synchronized会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同一个对象synchronized就会阻塞等待

  • 进入synchronized修饰的代码块,相当于加锁
  • 退出synchronized修饰的代码块,相当于解锁

(2)可重入

一个线程针对同一个对象,连续加锁两次,是否会有问题:如果没问题,就叫可重入的;如果有问题,就叫不可重入的(阻塞)。

如果对上述的Counter类的add方法加synchronized,就是对同一个对象连续加锁两次:

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

锁对象是this,只要有线程调用add,进入add方法的时候就会先加锁(能够加锁成功),紧接着又遇到了代码块,再次尝试加锁。如果能够加锁成功,这个锁就是可重入的,如果不成功,就是不可重入的,这个情况会导致线程产生死锁的情况。而在上面这种情况下是可以加锁的,因为第一个线程和第二个线程是同一个线程,就能加锁成功。实际上在Java中这种情况很容易出现,为了避免不小心就死锁,Java就把synchronized设定成可重入了。

3. Java标准库中的线程安全类

如果是多个线程操作同一个集合类,就需要考虑到线程安全的事情。

以下的类在多线程的使用中需要格外注意,当有线程安全问题的时候就需要手动加锁:

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • StringBuilder

以下的类是线程安全的,内置了synchronized加锁,相对来说更安全一点:

  • Vector(不推荐使用)
  • HashTable(不推荐使用)
  • ConcurrentHashMap
  • StringBuffer(不推荐使用) 

还有的虽然没有加锁但是不涉及“修改” ,仍然是线程安全的

  • String

五、死锁

死锁是一个非常影响程序猿幸福感的问题。一旦程序出现死锁,就会导致线程就挂掉了(无法执行后续工作),程序势必会有严重bug。而且死锁是非常隐蔽的,开发的时候不经意就容易写出死锁代码,而且不容易测试出来。

1. 死锁的三个典型情况

(1)一个线程,一把锁,连续加锁两次

如果锁是不可重入锁,就会死锁。

(2)两个线程两把锁,t1 和 t2 各自针对 锁A 和 锁B 加锁,再尝试获取对方的锁。

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

        Thread t1 = new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);//线程抢占式执行,先确保两个线程先把第一个锁拿到
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2){
                    System.out.println("t1");
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker1){
                    System.out.println("t2");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

如上代码就是两个线程相互获取对方的锁,就会出现两个线程没有获取到锁的情况,也就是相互阻塞:

 使用jconsole查看线程情况,可以看到两个线程的阻塞位置,都在尝试获取对方的锁,但是都处于BLOCKED状态:

 

 

针对这样的死锁问题,需要借助jconsole这样的工具进行定位,看线程的状态和调用栈,就可以分析出代码是在哪里出现了死锁。 

(3)多个线程多把锁

可以通过 哲学家就餐问题 这个案例了解这种情况。

2. 可重入不可重入(见上文)

3. 死锁的四个必要条件

(1)互斥使用

(2)不可抢占

线程1拿到锁之后,必须是线程1主动释放,不能说是线程2把锁强行获取到

(3)请求和保持

线程1拿到锁A之后,再尝试获取锁B,A这把锁还是保持的(不会因为获取锁B就把A给释放了)

(4)循环等待

线程1尝试获取到锁A和锁B,线程2尝试获取到锁B和锁A

线程1在获取B的时候等待线程释放B;同时线程2在获取A的时候等待线程1释放A。

4. 如何破除死锁

死锁的四个必要条件中,前三个都是属于锁的基本特性,无法控制,程序猿能控制的只有循环等待,这是四个条件中里唯一和代码结构相关的。那么如何避免死锁呢,就需要在循环等待这个条件上进行相关的操作。

办法:

给锁编号,然后指定一个固定的顺序(比如从小到大)来加锁。任意线程加多把锁的时候,都让线程遵守上述顺序,此时循环等待自然破除

六、volatile关键字

1. volatile能保证内存可见性

这个关键字和内存可见性密切相关,我们先来看代码,做了两个线程t1和t2,t1负责循环快速重复读取,t2负责修改,我们预期结果:t2把flag改成非0值之后,t1随之结束循环。

import java.util.Scanner;

class MyCounter {
    public int flag = 0;
}

public class ThreadDemo15 {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();
        //t1进行读
        Thread t1 = new Thread(() -> {
            while (myCounter.flag == 0) {
                //这个循环体为空
            }
            System.out.println("t1 循环结束");
        });
        //t2进行写
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            myCounter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

运行代码,通过jconsole观察线程:

t1正在在进行循环的判定:

 同时t2线程也是正在运行:

 接下来在控制台输入具体的数值,比如说输入1,当输入1之后,发现t1的循环并没有退出:

这和我们预期的结果不同 ,同时在jconsole看到t2线程已经没有了,执行完了:

那么这种情况就叫做“内存可见性问题”,这种情况也是属于bug,也是线程不安全问题,是一个线程读,一个线程改的情况,它是有可能导致线程不安全的情况的。

那么这里面是怎么导致的呢,我们来分析代码。

首先是t1线程,t1线程最核心的操作是循环比较,它用汇编语言的角度来理解的话,分为两步操作:

1. load:把内存中的flag的值,读取到寄存器中,

2. cmp:把寄存器的值和0进行比较,根据比较结果决定下一步往哪个地方执行(条件跳转指令)

注意上述是个循环,这个循环中什么事情都没做,那么它的执行速度就极快,一秒钟执行百万次以上,这里就有了问题,执行load和cmp这么多次,在t2真正修改之前,每次load的结果都是一样的。另一方面,load操作和cmp操作相比,速度极慢(CPU针对寄存器的操作,要比内存快很多,大概3~4个数量级,而计算机对于内存的操作,比硬盘快3~4个数量级)。那么这里由于load的执行速度太慢(相对于cmp),再加上反复load到的结果都一样,JVM就对编译器做了一个优化:不再进行重复load,直接判定没人修改flag值,只读取一次load。但是实际上这里的问题就在于flag是有t2在进行修改的,所以JVM/编译器对于这种多线程的情况,判定就可能存在误差。

总结一下,内存可见性问题,就是一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值,不一定是修改之后的值。这个读线程没有感知到变量的变化,归根结底是编译器/JVM在多线程环境下进行优化时产生了误判了,这种情况要解决就需要程序猿手动通过volatile关键字对变量进行干预了。

加volatile关键字,意思就是告诉编译器该变量是“易变的”,每次都需要重新读取这个变量的内存内容,不能再进行激进的优化。

class MyCounter {
    volatile public int flag = 0;
}

我们加了volatile之后来看一下运行结果,可以看到结果按照预期的正常运行了:

 注:上述介绍的内存可见性中编译器优化的问题也并不是100%会出现的,编译器只是可能会存在误判情况,并不是说一定会出现误判,如下的调整代码,对t1线程进行sleep调整,调整之后可以在运行结果中看到不加volatile也是可以正确运行的。

        Thread t1 = new Thread(() -> {
            while (myCounter.flag == 0) {
                //这个循环体为空
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 循环结束");
        });

运行结果:

实际上,编译器的优化很多时候是一个“玄学问题”,站在应用程序的角度是无法感知的。因此稳妥的做法,就还是把该加volatile的地方都加上。

2. volatile不保证原子性

原子性是靠synchronized来保证的。synchronized和volatile都能保证线程安全,但是两个关键字使用场景不同,不能使用volatile处理两个线程并发++这样的问题。

七、wait和notify

前面已经提到,线程最大的特点是抢占式执行,随机调度,但是我们写代码比较排斥随机执行这样的事情,所以程序猿就发明了wait和notify来控制协调多个线程之间的执行顺序。

比如当前有t1和t2两个线程,希望t1先干活,干的差不多了,再让t2来干,就可以让t2阻塞,也就是让t2先wait,后续t1干的差不多了,再使用notify通知t2,对其进行唤醒,让t2干活。

我们先看如下的代码,我们对wait不加任何参数,就是使其一直等待,直至有线程唤醒它:

public class ThreadDemo16 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        object.wait();
    }
}

运行上述代码之后会看到报了异常,在异常信息中我们可以读取到:非法的锁状态异常

 由上文可以知道,锁的状态为被加锁和被解锁两种状态,那么为什么会有上面的异常呢?这需要理解wait的操作做了什么事情:

1. 释放锁

2. 进行阻塞等待

3. 收到通知之后,重新尝试获取锁,并且在获取锁之后,继续往下执行。

所以由于没有锁,wait第一步释放锁的操作就无从下手,于是编译器就会报出异常。

因此wait操作需要搭配synchronized来使用,我们对代码进行加锁操作:

public class ThreadDemo16 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object){
            System.out.println("wait之前");
            object.wait();
            System.out.println("wait之后");
        }
    }
}

运行结果如下, 

在jconsole中可以看见此时线程状态为WAITING ,那么在这里虽然wait是阻塞在synchronized代码块里,实际上这里的阻塞是释放了锁的,此时其他线程是可以获取到object这个对象的锁的。

 

注:wait和synchronized的对象必须是同一个,否则会报出上面的异常。


感受一下wait和notify的工作过程:

public class ThreadDemo17 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        //等待线程t1
        Thread t1 = new Thread(()->{
            System.out.println("t1:wait之前");
            try {
                synchronized (object){
                    object.wait();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t1:wait之后");
        });
        //通知线程t2
        Thread t2 = new Thread(()->{
            System.out.println("t2:notify之前");
            //notify获取到锁才能进行通知
            synchronized (object){
                object.notify();
            }
            System.out.println("t2:notify之后");
        });
        t1.start();
        t2.start();
    }
}

在代码中,我们要求线程的synchronized、wait、notify针对的对象都是相同的,代码才能正确生效,否则就是上文提到的非法的锁状态。

其实如果直接写两个线程start的代码,执行结果可能不会那么理想。

由于线程调度的不确定性,我们不能保证一定是先执行wait后执行notify。如果调用notify,此时没有人wait,此处的wait是无法被唤醒的,就相当于notify是一行无效代码,所以就可能有如下两种运行结果,一种是卡在WAITING状态,一种是正常执行。

 

那么如何保证t1先执行,t2后执行?可以在t1与t2之间加入sleep:

 

 注:sleep不一定500ms就可以完全保证t1先执行,t2后执行,这取决于电脑性能,极端情况下会有可能线程调度时间超过500ms,还是有可能t2先执行notify的。

加入sleep之后运行结果如下:

很明显可以看出,此处先执行了wait,然后被阻塞了,wait之后没有打印,然后到t2执行,进行到notify的时候,把t1的wait唤醒,t1继续执行。

 完整代码:

public class ThreadDemo17 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        //等待线程t1
        Thread t1 = new Thread(()->{
            System.out.println("t1:wait之前");
            try {
                synchronized (object){
                    object.wait();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t1:wait之后");
        });
        //通知线程t2
        Thread t2 = new Thread(()->{
            System.out.println("t2:notify之前");
            //notify获取到锁才能进行通知
            synchronized (object){
                try {
                    Thread.sleep(3000);//让t2更慢点
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                object.notify();
            }
            System.out.println("t2:notify之后");
        });
        t1.start();
        Thread.sleep(500);
        t2.start();
    }
}

wait与sleep用法是类似的,但是其实它们两个是有本质差别。

虽然两者都能指定等待时间,也都能被提前唤醒(wait用notify唤醒,sleep用interrupt唤醒),

但是notify唤醒wait是不会有任何异常的,而interrupt唤醒sleep就会出异常。


再举一个案例进一步理解wait让调用线程进行阻塞,通过其他线程的notify进行通知:

有三个线程,分别只能打印A、B、C,写代码,保证这三个线程固定按照ABC这样的顺序进行打印:

public class ThreadDemo18 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            System.out.println("A");
            synchronized (locker1) {
                locker1.notify();
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    locker1.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("B");
            synchronized (locker2) {
                locker2.notify();
            }
        });
        Thread t3 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    locker2.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("C");
        });
        t2.start();
        t3.start();
        Thread.sleep(1000);
        t1.start();
    }
}

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值