java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java SE 多线程volatile与wait/notify

Java SE多线程之线程安全、synchronized、volatile与wait/notify全解

作者:桃也在Coding

文章详细解释了Java中线程不安全的概念、原因及解决方案,并介绍了`synchronized`、`volatile`的使用方法和特性,以及`wait`/`notify`机制在多线程协作中的作用

一、线程不安全

1.1 线程不安全的直观现象

多线程的优势是提升效率,但多个线程同时操作共享数据时,极易出现数据错乱的问题,这就是线程不安全。

计数器案例:

private static int count = 0;
public static void main(String[] args) throws InterruptedException {
    // 线程1:自增5万次
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) count++;
    });
    // 线程2:自增5万次
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) count++;
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    // 预期10万,实际永远小于10万
    System.out.println("count: " + count);
}

运行结果永远小于预期的10万,这就是典型的线程不安全问题。

1.2 线程不安全的原因

操作系统对线程的调度是随机的,这也是线程不安全的罪魁祸首。

(1)原子性缺失:操作被拆分打断

原子性指一段操作不可分割,要么全部执行,要么全部不执行

count++看似一行代码,实则对应3个CPU的指令:

  1. load:把内存中的值加载到CPU寄存器中
  2. add:把寄存器的内容+1
  3. save:把寄存器中的内容保存回内存中

执行这三个指令时不一定能一次执行完,很有可能1和2执行完,调度走;过了很久,再调度回来执行3。

如果两个线程对于同一个count进行操作,极大概率t1还没来得及保存新结果(1),t2就已经加载并修改,保存了数据(0 -> 1),此时再调度t1,t1接下来该保存新数据(1),t1的修改覆盖了t2的修改,对于这两次自增,count的结果是1。这样多次覆盖,就会导致count最终的结果小于100000

(2)可见性缺失:数据更新互相看不见

Java内存模型(JMM)规定线程有独立工作内存,共享数据存主内存

(3)有序性缺失:指令被乱序优化

为提升效率,编译器和CPU会对指令重排序(不影响单线程结果),但多线程下会打乱逻辑:

二、synchronized

synchronized是Java内置的互斥锁,能同时保证原子性、可见性、有序性,是解决线程安全最常用的关键字。

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

2.1 三大特性

(1)互斥性(原子性)

synchronized用的锁是存在Java对象里的,可以粗略的理解为每个对象在内存中存储时,都有一块内存表示当前锁定的状态(类似于厕所的有人/无人)

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

  • 上个一个线程解锁后,下一个线程不是立即就能获取,而是靠操作系统来“唤醒”,这也是操作系统线程调度的一部分工作
  • 假设A B C三个线程,线程A先获得锁,然后B尝试获取,C再尝试获取。此时B和C都在阻塞队列中排队等待,但是当A释放锁之后,虽然B比C先来,B不一定立即获得锁,而是重新与C竞争。

(2)可见性

(3)可重入性

理解“把自己锁死”
一个线程没有释放锁,又尝试重新加锁
例如第一次加锁,成功上锁;第二次加同一把锁,锁已经被占用,就会阻塞等待。
按照之前的锁的设定,第二次加锁,阻塞等待,直到第一次的锁释放;而释放第一个锁也是由该线程完成,这样就陷入了死循环,把自己锁死了。
这样的锁称为“不可重入锁”

Java的synchronized引入了可重入的概念,同一线程可重复获取同一把锁,不会自己锁死自己。

底层通过线程持有者+计数器实现:加锁时计数器+1,解锁时计数器-1,计数器为0时真正释放锁。

2.2 三种使用方式

(1)修饰代码块(锁自定义对象)

// 锁任意对象
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            synchronized (lock) { // 加锁
                count++;
            } // 解锁
        }
    });
}

对于锁来说任意对象都可以,一般情况下,我们会专门定义一个Object类给锁使用。

(2)修饰实例方法(锁当前对象)

// 锁当前实例对象
public synchronized void increment() {
    count++;
}

(3)修饰静态方法(锁类对象,全局唯一)

// 锁当前类的Class对象,所有实例共享一把锁
public synchronized static void increment() {
    count++;
}

2.3 修复计数器案例

count++synchronized锁,保证原子性:

private static int count = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            synchronized (lock) { count++; }
        }
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            synchronized (lock) { count++; }
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println("count: " + count); // 输出10万
}

2.4 死锁

死锁是指两个或多个线程,各自拿着对方需要的锁,又互相等待对方释放锁,谁都不放手、谁都走不了,程序永久阻塞卡死。

构成死锁的必要条件

  1. 锁是互斥的。一个线程拿到锁后,另一个线程想要拿锁必须阻塞等待
  2. 锁不可剥夺。线程1拿到锁,线程2也想获取这个锁,必须阻塞等待,而不能直接抢占
  3. 请求和保持。一个线程拿到锁A,不释放锁1的前提下,获取锁B
  4. 循环等待。 多个线程的等待过程构成了循环,例如A等B释放,B也在等A释放

如何避免死锁

前两个条件是锁的基本特性,想要避免死锁的出现就要破坏掉3或4.

  1. 统一锁的获取顺序
    所有线程都按固定顺序拿锁,例如约定从序号小的锁开始获取,如果锁已经被获取,就要阻塞等待
  2. 放弃请求与保持
    要么一次性把需要的锁全部拿到,一把都不拿不到就不执行。
  3. 设置超时
    尝试拿锁等待一段时间,拿不到就放弃,不永久阻塞。
  4. 减少嵌套加锁

2.5 Java标准库中的线程安全类

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

线程安全类:

StringBuffer是线程安全的,正是因为其中的方法都有synchronized。

但是由于synchronized的限制,代码中可能出现锁的竞争导致阻塞,会使代码的效率大打折扣

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

三、volatile

3.1 内存可见性问题

以下面代码为例:t2改变flag的值来影响t1的执行,当输入1时,理论上t1内while条件不成立,应该跳出循环,线程结束。然而实际上输入1后t1线程还在继续

public class demo18 {
    private static int flag = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(flag==0){
            }
            System.out.println("t1 线程结束");
        });
        Thread t2 = new Thread(()->{
            //修改flag
            Scanner scan = new Scanner(System.in);
            System.out.println("输入flag的值:");
            flag = scan.nextInt();
        });
        t1.start();
        t2.start();
    }
}

很明显,这也是由于线程安全导致的bug。一个线程在读取,一个线程在修改,修改的值没有被另一个线程读取到,这就是“内存可见性问题”。

这涉及到编译器优化

我们写的代码,都会通过javac.java文件编译成.class字节码文件,由jvm执行。编译器可以保持代码逻辑不变的情况下,对代码进行优化,提升效率。

而在多线程场景,编译器很有可能判断错误,导致优化前后的逻辑不完全相同。

分析编译器如何误判

t1中是空循环,对于CPU主要就是两个操作:load (加载flag的值),cmp(条件跳转)

每轮循环执行速度非常快,短时间内就可以执行很多次,每次读取flag的值都是不变的。经过多次循环,JVM认为这个读取操作可以被优化(正是因为load在循环中时间消耗是cmp的几千倍),因此把读内存操作改成了读寄存器操作。

而用户输入值可能要经过好几秒,与上述的操作时间完全不是一个量级。等到用户真的输入flag的值,t1已经感知不到了(编译器优化使得t1的读操作不是真正的读内存)

如果在循环中加入一些语句

while(flag==0){
	sleep(1);
   }

加入sleep后,使循环的速度大大大大幅度下降,此时load时间占比对于整个循环小了很多,JVM认为这个优化没有必要,每次都是读内存操作。因此t2的修改可以被t1感知到,结果正确。

3.2 volatile的作用

volatile轻量级并发关键字不保证原子性,仅保证可见性和禁止指令重排序,适合解决“一个线程写、多个线程读”的场景。

(1)保证可见性:数据更新立即同步

解决线程感知不到变量更新的问题:

public class demo18 {
    private volatile static int flag = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(flag==0){
            }
            System.out.println("t1 线程结束");
        });
        Thread t2 = new Thread(()->{
            //修改flag
            Scanner scan = new Scanner(System.in);
            System.out.println("输入flag的值:");
            flag = scan.nextInt();//输入后t1就可以感知到,跳出循环,线程结束 
        });
        t1.start();
        t2.start();
    }
}

(2)禁止指令重排序:避免逻辑错乱

volatile适合无复合操作(如count++)、仅需可见性/有序性的场景;复合操作必须用synchronizedAtomicInteger

四、wait 等待 / notify 通知

多线程不仅要“互斥”,还要“协作”——比如生产者生产完数据,通知消费者消费;消费者无数据时等待。wait()notify()notifyAll()是实现线程等待-唤醒的核心方法,定义在Object类中。

4.1 方法详解

(1)wait():让线程等待并释放锁

(2)notify():随机唤醒一个等待线程

(3)notifyAll():唤醒所有等待线程

注意:

  1. 调用wait(),notify()以及这两个方法所在的synchronized代码块内必须是同一对象才能生效
  2. 要确保先wait 再notify,才会有作用。如果先notify再wait,不会对notify所在的线程有影响,但是没啥用

4.2 wait()与sleep()的区别

特性wait()sleep()
所属类Object类Thread类
锁行为释放锁不释放锁
唤醒方式需notify()/notifyAll()唤醒超时自动唤醒
使用场景线程协作(等待-唤醒)线程休眠(暂停执行)

如果sleep()在synchronized代码块内,就会出现“抱着锁睡”的情况,休眠期间其他线程也不能拿到这把锁。

到此这篇关于Java SE多线程之线程安全、synchronized、volatile与wait/notify全解的文章就介绍到这了,更多相关Java SE 多线程volatile与wait/notify内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文