多线程系列四:这就是超级重要的线程安全?

1.理解线程安全

线程安全就是某个代码无论是在单线程还是多线程下执行都不会产生bug就称为线程安全,如果代码在单线程下运行正确,多线程下运行不正确就称线程不安全

观察下列代码

public class demo4 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        t.start();
        t1.start();
        t.join();
        t1.join();
        System.out.println(count);
    }
}
//输出结果
66928

Process finished with exit code 0

为什么结果不是100000呢?需要我们理解count++的工作原理

count++是由三个CPU指令构成的:
1️⃣load:从内存中读取数据到CPU寄存器
2️⃣add:把寄存器中的值+1
3️⃣save:把寄存器的值写回到内存中

如果一个线程执行上述三个指令没问题,如果是两个线程,并发执行上面的操作,此时就会存在变数,因为线程间调度顺序是不确定的
在这里插入图片描述
关键问题在于确保第一个线程的save后第二个线程再load,这时第二个线程load的才是第一个线程自增后的结果

2.线程不安全的原因

线程调度是随机的,抢占式执行,这就是线程安全的罪魁祸首
上述多线程修改操作,本身不是原子性的,count++是多个CPU指令,一个线程执行这些指令,执行到一半可能被调度走,给其他线程“可乘之机”
不保证原子性给多线程带来的问题:如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的,这点也和线程的抢占式调度密切相关,如果线程不是抢占的,就算没有原子性,问题也不大

3.解决之前代码的线程不安全问题

public class demo4 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        Thread t = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (object){
                    count++;
                }
            }
        });
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (object){
                    count++;
                }
            }
        });
        t.start();
        t1.start();
        t.join();
        t1.join();
        System.out.println(count);
    }
}

4.非常重要的synchronized关键字

4.1概念

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

加锁的目的:把count++的三个操作打包成一个原子,更严谨来说是,通过锁竞争让第二个线程的指令无法插入到第一个线程的指令中间,而不是禁止第一个线程被调出CPU
进行加锁时,要先准备好锁对象,加锁和解锁操作都依赖于这里的锁对象来展开的

如果一个线程针对一个对象加上锁后,其他的线程也尝试对这个对象加锁,会产生阻塞(BLOCKED),一直阻塞到前一个线程释放锁为止
进一步展开,如果有两个线程,分别针对不同的对象加锁,此时就不会有锁竞争,也不会有阻塞

4.2加锁之后的执行过程

在这里插入图片描述

1️⃣:t1先执行lock,加锁可以成功
2️⃣:t2后执行lock时,由于所已经被t1加上了,t2就会阻塞等待,堵塞到t1线程unlock后t2才能获取到锁

  • 加锁后确实会影响多线程效率,但是也比一个线程执行的快

4.3对象

锁对象可以是任意一个Object对象,重要的是两个线程之间是否使用的同一个对象,是同一个对象就会产生竞争,不是一个对象就不会有竞争关系
几点注意:

  1. 如果一个线程加锁,一个线程不加锁,也会产生线程安全问题
  2. 如果两个线程针对不同对象加锁,也会产生线程安全问题
  3. 如果synchronized加到static方法上,就等价于给类对象加锁

4.4可重入

synchronized对同一条线程来说是可重入的,不会出现自己把自己锁死的问题,当前是同一个线程,此时锁对象就知道了第二次加锁的线程就是持有锁的线程,第二次操作就可以直接放行通过,不会出现阻塞

理解可重入锁的内部原理

在这里插入图片描述

  1. 在可重入锁内部,包含了“现成持有者”和“计数器”两个信息
  2. 如果某个线程加锁的时候,发现锁已经被人占用,但是恰好占用的正是自己,那么仍然可以继续获取到锁,并让计数器自增
  3. 解锁的时候计数器递减为0的时候才是真正释放锁(才能被别的线程获取到)

4.5死锁

死锁的三种典型场景

  1. 一个线程一把锁:上述情况如果锁是不可重入锁,并且一个线程对这把锁加锁两次,就会出现死锁(钥匙锁屋里了)
  2. 两个线程两把锁:线程1获取锁A,线程2获取锁B,接下来,1尝试获取B,2尝试获取A,就出现死锁,一旦出现死锁,线程就会卡住,无法继续工作(钥匙锁车里了,车钥匙锁屋里了)
  3. n个线程m把锁(哲学家吃面条问题)

产生死锁的四个必要条件(缺一不可)

  1. 互斥使用,获取锁的过程是互斥的,一个线程拿到了锁,另一个线程也想获取,就要阻塞等待
  2. 不可抢占,一个线程拿到锁之后,只能主动解锁,不能让别的线程强行把锁抢走
  3. 请求保持,一个线程拿到锁之后,在持有A的前提下,尝试获取B
  4. 循环等待

解决死锁的问题,核心思路就是破坏上述条件,只要破坏一个即可,但是,最容易破坏的就是循环等待,只需指定加锁顺序,针对五把锁,都进行编号,约定每个线程获取到锁时,一定要先获取编号小的,后获取编号大的

4.6synchronized使用示例

  1. 修饰代码块,明确指定锁哪个对象
private Object locker = new Object();
    public void func(){
        synchronized (locker){
            
        }
    }
  1. 锁当前对象
public void func(){
        synchronized (this){

        }
    }
  1. 直接修饰普通方法
public synchronized void func(){
        
    }
  1. 修饰静态方法
public static synchronized void func(){

    }

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

Java标准库中很多线程都是不安全的,这些类可能会涉及到多线程修改共享数据,又没有加任何措施

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

下列这几个类自带锁,在多线程环境下使用能好点

  • Vector
  • HashTable
  • ConcurrentHashMap
  • StringBuffer

String虽然没有加锁,但是不涉及修改操作,仍然是线程安全的

6.volatile关键字

一个线程写,一个线程读,也可能出现线程安全问题
volatile能保证内存可见性
代码在写入volatile修饰变量的时候,改变线程工作内存中volatile变量副本的值,将改变后的副本的值从工作内存刷新到主内存,代码在读取volatile修饰变量的时候,从主内存中读取volatile变量的最新值到线程的工作内存中,从工作内存中读取volatile变量的副本

  • 代码示例
import java.util.Scanner;

public class demo7 {
    static class Counter{
        public int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            while(counter.flag==0){

            }
            System.out.println("循环结束!");
        });
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            counter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

当用户输入非0值时,t1线程循环不会结束,这是一个bug
为什么呢?
只需在t1的while循环中加入sleep即可

比较的核心指令有两条:
1️⃣:load读取内存中flag的值到CPU寄存器中
2️⃣:拿着寄存器的值和0进行比较
由于循环执行的速度非常快,有两个要点:load操作执行的结果每次都是一样的,load操作开销远超条件跳转。
频繁执行load和条件跳转,load开销大,且load结果没变化,此时JVM怀疑load操作是否有存在的必要,此时JVM可能做出代码优化,把上述load操作优化掉,就不再重复读取内存,直接用寄存器中之前缓存的值,大幅提高循环的执行速度
多线程下产生误判,这里t2修改内存,但t1没看到这个内存的变化,就称内存可见性问题
内存可见性高度依赖编译器的优化的具体实现,编译器啥时候优化,啥时候不触发优化不好说

所以给变量加上volatile,使得优化强制关闭,确保每次循环条件都会重新从内存中读取数据了,虽然开销变大了,但是准确性提高了

  • 上述代码改进方法1:给while循环中加入休眠
import java.util.Scanner;

public class demo7 {
    static class Counter{
        public int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            while(counter.flag==0){
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("循环结束!");
        });
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            counter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

1
循环结束!

Process finished with exit code 0


  • 改进方法2:给变量加上volatile
import java.util.Scanner;

public class demo7 {
    static class Counter{
        public volatile int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            while(counter.flag==0){

            }
            System.out.println("循环结束!");
        });
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            counter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}
1
循环结束!

Process finished with exit code 0

7.wait方法

引入wait和notify方法就是为了能从应用层面上干预到多个不同线程代码的执行顺序,这里说的干预,不是影响系统的线程调度策略,相当于是在应用程序代码中,让后执行的程序主动放弃被调度的机会,就可以让先执行的线程先把对应代码执行完


由于线程之间是抢占式执行的,因此线程之间执行的先后顺序难以预知,但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序

7.1wait方法

wait要做得事情:
1️⃣:释放锁
2️⃣:进入阻塞等待
3️⃣:当其他线程调用notify时,wait解除阻塞,并重新获取到锁

wait要搭配synchronized来使用,脱离synchronized使用会直接抛出异常,因为wait首先要做的是释放锁


wait结束等待的条件:
1️⃣:其他线程调用该对象的notify方法
2️⃣:wait等待时间超时
3️⃣:其他线程调用该等待线程的Interrupted方法,导致wait抛出异常

观察下列代码,就会一直等待下去,那么这是我们就需要另一个唤醒的方法:notify

public class demo9 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object){
            System.out.println("等待中!");
            object.wait();
            System.out.println("结束等待!");
        }
    }
}

7.2notify方法

notify方法就是唤醒等待的线程

wait和notify是通过Object对象关联起来的,必须两个对象一致才能唤醒
如果有多个线程等待,则由线程调度器随机挑选一个wait状态的线程,没有先来后到的规则
在执行完notify方法后,当前线程不会马上释放该对象锁,要等到执行notify方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁

7.3notifyAll方法

notify方法只是唤醒某一个等待线程,使用notifyAll方法可以一次唤醒所有等待的线程
但要注意,这些线程在wait返回时,要重新获取锁,就会因为锁的竞争,使这些线程实际上是一个个串行执行的,谁先拿到是不确定的

8.面试题:wait和sleep的区别

1️⃣:wait提供了一个带超时时间的版本,sleep也能指定时间,都是时间到了就继续执行,解除阻塞
2️⃣:wait和sleep都能被提前唤醒,wait通过notify唤醒,sleep通过Interrupt唤醒
3️⃣:使用wait最主要的目标一定不知道要等待多少时间的先提下使用的,所谓超时时间是兜底的,使用sleep一定是知道要等多少时间的

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值