JAVA线程安全
目录
一、引言(原子性问题):
二、出现线程安全的原因:
1、线程的执行调度是随机的(抢占式执行)。
2、多个线程修改变量。
3、修改操作是不是原子性的。
4、内存可见性。
5、线程之间的执行顺序:
三、解决线程安全的办法:
1、synchronized 关键字:
1、synchronized (锁对象) { }
2、synchronized 关键字修饰普通方法:
3、synchronized 修饰静态方法:
2、可重入:
3、解决内存可见性引起的线程安全(使用 volatile 关键字)
4、(协调线程之间的执行顺序)wait 和 notify 关键字
wait():
notify()和 notifyAll():
一、引言(原子性问题):
先来看一段代码:
public class Demo1 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 5000; i++) {count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 5000; i++) {count++;}});//启动线程t1.start();t2.start();t1.join();t2.join();//打印 count 的结果System.out.println(count);}
}
分别运行几次的结果:
可以看到,count 的结果每次运行都不一样,为什么呢?
因为 count++
操作在多线程环境下不具备原子性,这是导致 count
结果每次不同的根本原因。
count++ 这个操作在CPU中,有三个执行流程:
1.读取:
CPU会从内存中读取 count 当前的值,然后将其加载到CPU的寄存器当中。
2.操作:
寄存器会将读取到的值(count)进行加 1 操作(因为这里是count++)。
3.写回:
寄存器会将操作后的值写回内存当中。
所以,在多线程的情况下:
线程 t1 和 线程 t2 同时执行的时候:
线程 t1 从内存读取到的 count 的值为 0 并加载到寄存器完成加一操作后,接着线程 t2 也读取 count 的值 ,由于线程 t1 还没来得及将 加一 后的值写回内存,所以线程 t2 读取到的 count 依然是 0 ,然后线程 t1 和线程 t2 分别在各自的寄存器中对 0 进行加 1 操作得到 1,最后线程 t1 和线程 t2 先后将 1 写回到内存。这样,两次 count++
操作之后,count
的值本应是 2,但实际结果却是 1,这就造成了数据丢失的情况。
后续的 count++ 操作可能会是 t2 先读取,再到 t1 读取,流程与上述类似,上述只是代表其中一种情况。只要一个线程还没把操作完的数据写到内存,而另外一个线程提前读取内存当中的值,就会出问题。这种情况就是原子性问题。
另外,基于匿名内部类,如果count定义为局部变量,会怎么样:
public class Demo2 {public static void main(String[] args) throws InterruptedException {int count = 0;Thread t1 = new Thread(() -> {for (int i = 0; i < 5000; i++) {count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 5000; i++) {count++;}});//启动线程t1.start();t2.start();t1.join();t2.join();//打印 count 的结果System.out.println(count);}
}
其实这里 count 已经报红线了,把鼠标放在 count++会有这样的提示:
这里是lambda表达式的变量捕获,lambda表达式如果想要正确捕获外部局部变量要求是 final 类型或者 事实 final(事实 final 是指 虽然没有加 final 关键字,但是代码中确实没有修改这个变量)。
如果把 count 写成外部成员变量:
public class Demo2 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 5000; i++) {count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 5000; i++) {count++;}});//启动线程t1.start();t2.start();t1.join();t2.join();//打印 count 的结果System.out.println(count);}
}
这里还是存在线程安全问题。
但是 count 写成成员变量后,在 lambda表达式中确实可以使用,但此时lambda走的不是“变量捕获”语法,而是“内部类访问外部成员”,lambda本质就是匿名内部类,内部类访问外部类成员,本身就是可以的。
二、出现线程安全的原因:
1、线程的执行调度是随机的(抢占式执行)。
这是线程安全的根本原因。
在大多数现代操作系统中,线程的调度执行是抢占式的。在抢占式调度的多线程环境中,可能会出现线程还没将修改后的值写回内存,另外的线程就发生下一次读取的情况。
时间片中断:线程可能在任意指令执行过程中被中断(如 count++
的读取、计算或写入阶段),CPU 时间片耗尽后立即切换线程。
2、多个线程修改变量。
1、 一个线程修改一个变量;这是没问题的。
2、多个线程读取同一个变量;这是没问题的。
3、多个线程修改不同的变量;这是没问题的。
4、多个线程同时修改同一个变量;这是有问题的。
3、修改操作是不是原子性的。
什么是原子性:
从CPU执行指令的角度来看,如果是一条指令,对于CPU来说,要么就是执行完,要么就是不执行,不会出现“一个指令执行一半就中断”这样的情况。CPU执行一条指令,这个行为就是原子的。(可以参靠本文引言部分的解释来理解)。
1、如果修改操作不是原子性的,在多线程环境下同时修改同一个变量时极有可能出现线程安全问题。
2、如果修改操作是原子性的,在多线程环境下通常能保证其安全性。
基本数据类型变量的赋值操作是原子的。
比如:
//对 boolean 类型变量赋值:
boolean flag = true;//对 byte、short、char、int、float 类型变量赋值:
int num = 10;
float f = 3.14f;
4、内存可见性。
多线程环境下,线程对共享变量的操作在各自工作内存和主内存之间存在数据同步延迟,导致不同线程看到的共享变量值不一致,进而引发数据不一致等线程安全问题。
5、线程之间的执行顺序:
因为线程本身执行时随机调度的(顺序不确定),有时候我们希望两个线程在运行的时候,是能够有一定的顺序的。
比如希望线程1执行完某个逻辑后,在让线程2执行。
三、解决线程安全的办法:
1、synchronized 关键字:
通过 synchronized 关键字(加锁)的方式,就可以把若干个操作(包括非原子性)打包成一个 原子性 的操作。
下面是几种用法:
1、synchronized (锁对象) { }
synchronized (锁对象) {//执行逻辑......}
锁对象,在java中,任意一个对象都可以是这个锁对象。比如:
1、普通对象实例(new 的对象)
2、当前对象实例(this)
3、类的class对象(类名.class)
4、字符串常量(String )
而java内置类型(比如 int ,char,short,boolean)这些就不行。
要注意的是:
当多个线程访问同步代码块或同步方法时,如果它们使用的是同一个锁对象,那么这些线程就会竞争这把锁。只有获得锁的线程才能进入同步代码块或执行同步方法,其他线程则需要在锁的等待队列中等待,直到锁被释放。
比如,再看这个之前写过的代码:
package Demo3;public class Demo3 {public static int count = 0;//创建锁对象private static String s1 = "hello";public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 5000; i++) {synchronized (s1) {count++;}}});Thread t2 = new Thread(() -> {for (int i = 0; i < 5000; i++) {synchronized (s1) {count++;}}});//启动线程t1.start();t2.start();t1.join();t2.join();//打印 count 的结果System.out.println(count);}
}
运行多次,count 的结果都是 10000,因为,我们通过 synchronized 关键字加锁,保证了 count++ 这个操作是原子性的。
要注意的是:
加锁不是把 count++ 的三个指令变成一个指令了,是通过在代码块前后添加获取锁和释放锁的操作,来确保在同一时刻只有一个线程能够执行被锁保护的代码块。当一个线程执行 count++
操作时,其他线程会被阻塞,直到该线程完成操作并释放锁,这样就保证了 count++
操作的原子性,从效果上看就好像是一个不可分割的操作一样。
如图:
t1 加锁的时候往下继续执行,而 t2 也在 t1 执行的期间获取同一把锁,此时 t2 也是加锁,但实际是阻塞等待,一直阻塞到 t1 解锁的时候, t2 才真正拿到锁。
当 t1 解锁的时候,说明 t1 已经把 count 变量的的值写回内存了,到 t2 执行读取操作时,得到的数据就是 t1 已经写回到内存的数据。
本来读取,操作,写回内存 这三个操作两个线程时穿插进行的,但是引入锁之后,就变成了“串行执行”,不再穿插执行,就确保结果正确了。
2、
synchronized
关键字修饰普通方法:
Counter
类:
其中有一个变量 count
用于计数,还有一个被 synchronized
修饰的 add
方法,该方法会让 count
加 1 并输出当前调用这个方法的线程的名称与计数结果。
SynchronizedMethodExample
类:
在 main
方法里创建了一个 Counter
对象,同时创建了两个线程,每个线程都会调用 add 方法 5 次。借助 synchronized
修饰 add 方法,能够确保同一时刻只有一个线程可以执行该方法,进而避免多线程环境下的计数错误。
通过这种方式,synchronized
关键字就可以对普通方法进行修饰,从而实现线程同步。
实例代码:
class Counter {public int count = 0;// 使用 synchronized 修饰普通方法public synchronized void add() {count++;System.out.println(Thread.currentThread().getName() + " 计数: " + count);}
}public class Demo4 {public static void main(String[] args) throws InterruptedException {Counter counter = new Counter();// 创建两个线程来调用 add 方法Thread t1 = new Thread(() -> {for (int i = 0; i < 5; i++) {counter.add();}}, "线程1");Thread t2 = new Thread(() -> {for (int i = 0; i < 5; i++) {counter.add();}}, "线程2");// 启动线程t1.start();t2.start();// 等待两个线程执行完毕t1.join();t2.join();System.out.println("最终计数: " + counter.count);}
}
在 Java 里,每一个对象都有一个与之关联的监视器锁(也称为对象锁)。每个对象都有一个内部的锁,当使用 synchronized
修饰普通方法或者使用 synchronized(this)
代码块时,使用的就是这个对象的锁。同一时刻,这个锁只能被一个线程持有。
class Counter {public int count = 0;//使用 synchronized 修饰普通方法public synchronized void add() {count++;System.out.println(Thread.currentThread().getName() + " 计数: " + count);}// 第二种写法public void add2() {synchronized (this) {count++;System.out.println(Thread.currentThread().getName() + " 计数: " + count);}}
}
第二种写法这里使用了 synchronized
代码块,并且锁的对象是 this
,this
代表当前对象实例,也就是 Counter
类的对象。所以,它和使用 synchronized
修饰普通方法的效果是一样的,同样是同一时刻只有一个线程可以进入 synchronized
代码块执行其中的代码。
要注意的是:
当一个方法被 synchronized
修饰后,线程在调用该方法前,必须先获取该对象的锁。一旦获取到锁,线程就能执行该方法;若锁已被其他线程持有,此线程就得进入阻塞状态,等待锁被释放。
当一个类有两个方法都加了 synchronized
修饰,并且有两个线程分别调用这两个不同的方法,在普通方法(非静态方法)加锁的情况下,同一时间只能有一个线程进入其中一个加锁方法,另一个线程需要等待。
而未被 synchronized
修饰的方法不受这个对象锁的限制。也就是说,一个线程在执行对象的 synchronized
方法时,其他线程可以同时执行该对象未被 synchronized
修饰的方法。
如果是对象自定义的locker锁呢?
class Counter {public int count = 0;public String locker = "hello";//使用 synchronized 修饰普通方法public synchronized void add() {count++;System.out.println(Thread.currentThread().getName() + " 计数: " + count);}// 第二种写法public void add2() {synchronized (this) {count++;System.out.println(Thread.currentThread().getName() + " 计数: " + count);}}// 第三种写法public void add3() {synchronized (locker) {count++;System.out.println(Thread.currentThread().getName() + " 计数: " + count);}}
}
第三种方式锁定的是 locker
对象,与 Counter
对象本身的锁是相互独立的。也就是说,即使一个线程正在执行 synchronized(locker)
代码块,其他线程仍然可以执行该 Counter
对象的其他被 synchronized
修饰的普通方法或者 synchronized(this)
代码块(前提是它们不依赖于 locker
对象的锁)。
总结:
前两种方式锁定的是 Counter
对象本身,这意味着如果一个线程正在执行该对象的其他被 synchronized
修饰的普通方法或者 synchronized(this)
代码块,其他线程就不能进入这些同步区域。而第三种方式锁定的是 locker
对象,与 Counter
对象本身的锁是相互独立的。
3、synchronized 修饰静态方法:
class Counter {public static int count = 0;// 使用 synchronized 修饰静态方法public static synchronized void add() {count++;System.out.println(Thread.currentThread().getName() + " 计数: " + count);}
}public class Demo5 {public static void main(String[] args) throws InterruptedException {// 创建两个线程来调用 add 方法Thread t1 = new Thread(() -> {for (int i = 0; i < 5; i++) {Counter.add();}}, "线程1");Thread t2 = new Thread(() -> {for (int i = 0; i < 5; i++) {Counter.add();}}, "线程2");// 启动线程t1.start();t2.start();// 等待两个线程执行完毕t1.join();t2.join();System.out.println("最终计数: " + Counter.count);}
}
这里与 synchronized 修饰普通方法不同:
当 synchronized
修饰普通方法时,锁定的是调用该方法的对象实例。不同的对象实例有各自独立的锁,不同对象的同步方法可以被不同线程同时执行。
当 synchronized
修饰静态方法时,锁定的是该类的 Class
对象。在 JVM 中,每个类仅有一个 Class
对象,这意味着无论创建多少个该类的对象实例,同一时刻只有一个线程可以执行该类的静态同步方法。
还有另一种synchronized修饰静态方法的写法:
// 使用 synchronized 修饰静态方法public static synchronized void add() {count++;System.out.println(Thread.currentThread().getName() + " 计数: " + count);}//第二种写法public static void add2() {synchronized (Counter.class) {count++;System.out.println(Thread.currentThread().getName() + " 计数: " + count);}}
第二种写法,通过 类名.class 的方式,得到属于类的锁。
2、可重入:
了解可重入之前,先来了解一下什么是死锁:
public static void main(String[] args) {Object locker = new Object();synchronized (locker) {synchronized (locker) {//执行代码逻辑......// doSomething();}}}
这里主线程尝试获取同一把锁(locker)两次,会发生什么?
我们来分析一下,当最外层加锁成功后,走到内层加锁的位置,就会触发阻塞,想要解决阻塞,就得释放外层的锁,想要释放外层的锁,就要内层的加上锁,继续往下走,想要继续往下走,就得接触阻塞......;这样的情况,就构成了死锁。
那么,在java中,这样会不会也出现死锁的情况?
答案是在上面我写出的代码示例里,不会出现死锁情况。
因为在java里 synchronized 关键字所使用的锁是可重入锁,可重入锁的特性是:
同一个线程在已经有某个锁时,能够再次获取该锁而不会被阻塞,这是因为锁内部维护着一个计数器和持有该锁的线程标识。当线程首次获取锁,计数器初始化为 1 并记录线程标识;若同一线程再次请求该锁,计数器加 1;线程释放锁时,计数器减 1;当计数器为 0 时,才表示线程完全释放锁,其他线程才能竞争该锁。
在上面的代码中;主线程第一次进入 synchronized (locker)
代码块时获取锁,计数器为 1;再次进入内层 synchronized (locker)
代码块,由于是同一线程请求,计数器加 1 变为 2。后续每次退出 synchronized
代码块,计数器减 1,直至完全释放锁。所以,可重入锁的机制避免了同一线程多次获取同一把锁时发生死锁。
那么,java在什么情况会出现死锁?
1、锁顺序死锁:
public class Demo7 {private static final Object lock1 = new Object();private static final Object lock2 = new Object();public static void main(String[] args) {// 线程 1 先获取 lock1 再获取 lock2Thread thread1 = new Thread(() -> {synchronized (lock1) {System.out.println("线程1得到了锁lock1");try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("线程1尝试获取lock2......");synchronized (lock2) {System.out.println("线程1获取到了lock2");}}});// 线程 2 先获取 lock2 再获取 lock1Thread thread2 = new Thread(() -> {synchronized (lock2) {System.out.println("线程2得到了锁lock2");try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("线程2尝试获取lock1......");synchronized (lock1) {System.out.println("线程2获取到了lock1");}}});thread1.start();thread2.start();}
}
代码一直在等待,形成死锁。
线程1 持有 lock 1 并尝试获取 lock2,而线程2 持有lock2 并尝试获取 lock1,这就形成了一个循环等待,满足了环路等待条件,从而导致死锁。
还有一种情况也会形成死锁:
环路等待条件:存在一组线程,它们之间形成了一个首尾相接的循环等待链,每个线程都在等待下一个线程所占用的资源。例如,线程 A 等待线程 B 占用的资源,线程 B 等待线程 C 占用的资源,而线程 C 又等待线程 A 占用的资源,这样就形成了一个环路等待。
3、解决内存可见性引起的线程安全(使用 volatile 关键字)
有这么一段代码:
public class Demo8 {private static int flag = 0;private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {// 此处搞一个循环while (flag == 0) {count++;}System.out.println("t1 结束");});Thread t2 = new Thread(() -> {// 修改 flag 的值Scanner scanner = new Scanner(System.in);System.out.println("请输入一个 flag 的值: ");flag = scanner.nextInt();System.out.println("t2 结束");});t1.start();t2.start();t1.join();t2.join();}
}
当我输入 随机一个数字想要结束线程1的时候,可以看到,线程 t1 没有结束,为什么?
从计算机原理上解释:
计算机的内存架构中,每个线程都有自己的工作内存,线程对共享变量的操作先在工作内存中进行,之后才会刷新到主内存。同样,线程在使用共享变量时,会先从主内存将变量值加载到自己的工作内存。
当一个线程修改了工作内存中的共享变量后,如果没有及时将修改后的值刷新到主内存,其他线程就无法及时获取到该变量的最新值,从而造成数据不一致。
所以,在上面的代码中,t1
线程可能会将 flag
的值缓存到自己的工作内存中,当 t2
线程修改了主内存中 flag
的值后,t1
线程可能无法及时从主内存中读取到最新的 flag
值,依旧使用自己工作内存中的旧值,这样就会导致 t1
线程无法正常退出循环。
想要解决这个问题,就可以使用 volatile 关键字:
public class Demo8 {// 使用 volatile 关键字修饰 flag 变量private static volatile int flag = 0;private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {// 此处搞一个循环while (flag == 0) {count++;}System.out.println("t1 结束");});Thread t2 = new Thread(() -> {// 修改 flag 的值Scanner scanner = new Scanner(System.in);System.out.println("请输入一个 flag 的值: ");flag = scanner.nextInt();System.out.println("t2 结束");});t1.start();t2.start();t1.join();t2.join();}
}
可以看到,当我输入值后,两个线程都结束了。
volatile
关键字能够保证变量的可见性,即一个线程对 volatile
变量的修改会立即刷新到主内存,而其他线程在读取该变量时会直接从主内存中读取最新的值。
值得注意的是,volatile 这个关键字,能够解决内存可见性问题引起的线程安全问题,但是不具备原子性这样的特点。
synchronized 用于两个线程修改共享资源(两个线程修改)
volatile(一个线程读,一个线程改)
4、(协调线程之间的执行顺序)wait 和 notify 关键字
例如有个场景:
有两个线程,线程1 和 线程2,希望线程1 执行完某个逻辑之后,在让线程2执行。此时就可以让线程2通过 wait 主动进行阻塞,让线程1参与调度,等线程1把对应的逻辑执行完了,就可以通 notify 唤醒线程2 :
public class Demo9 {private static Object locker = new Object();public static void main(String[] args) {Thread t1 = new Thread(() -> {System.out.println("t1 wait 之前");synchronized (locker) {try {locker.wait();} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("t1 wait 之后");});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("请输入任意内容, 唤醒 t1");scanner.next();synchronized (locker) {locker.notify();}});t1.start();t2.start();}
}
分别对线程 t1 和线程 t2 分析:
线程 t1 :
首先输出 "t1 wait 之前"
。
进入 synchronized
块,获取 locker
对象的锁。
调用 locker.wait()
方法,这会使线程 t1
释放 locker
对象的锁,并进入等待状态,直到其他线程调用 locker.notify()
或 locker.notifyAll()
方法将其唤醒。
若被唤醒,线程 t1
会重新获取 locker
对象的锁,然后继续执行,输出 "t1 wait 之后"
。
线程 t2 :
提示用户输入任意内容。
等待用户输入后,进入 synchronized
块,获取 locker
对象的锁。
调用 locker.notify()
方法,随机唤醒一个在 locker
对象上等待的线程(这里就是线程 t1
)。
wait():
1、wait()
方法必须配合 synchronized
块或 synchronized
方法使用。
2、wait()使当前线程进入等待状态,释放持有的对象锁,直到其他线程调用同一对象的 notify()
或 notifyAll()
唤醒它。
3、wait(long timeout),单位是毫秒,是wait()的超时版本,这是允许线程在指定的时间内等待,若超过这个时间还未被唤醒,线程会自动苏醒继续执行后续代码。(若在等待线程超时苏醒时,有其他线程正在持有该对象的锁(在执行 synchronized
块的方法),那么等待线程会进入锁的等待队列,等待持有锁的线程释放锁)。
所以wait()做的事有:
1.使当前执行代码的线程进行等待。(把线程放到等待队列中)。
2.释放当前的锁。
3.满足一定条件被唤醒,并重新获取这把锁(等待获取锁后唤醒并继续执行 synchronized
块中 wait()
方法之后的代码。因为wait()在synchhronized代码块里面,还没出去)。
notify()和 notifyAll():
notify()
方法必须要在 synchronized
块或者 synchronized
方法中调用。
调用 notify()
方法后,线程不会立即释放锁,而是要等调用 notify()
的线程执行完 synchronized
块(或方法)后才会释放锁
public class Demo9 {private static Object locker = new Object();public static void main(String[] args) {Thread t1 = new Thread(() -> {System.out.println("t1 wait 之前");synchronized (locker) {try {locker.wait();} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("t1 wait 之后");});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("请输入任意内容, 唤醒 t1");scanner.next();synchronized (locker) {locker.notify();System.out.println("t2线程跑完了......");}});t1.start();t2.start();}
}
notify ( ) 方法的作用是唤醒在该对象上因调用 wait()
方法而处于等待状态的线程,但它本身并不影响锁的持有状态。调用 notify()
只是发出一个唤醒信号,告诉等待队列中的线程可以尝试重新竞争锁了,但当前持有锁的线程仍然会继续执行 synchronized
块或方法中的剩余代码,直到执行完毕才会释放锁。
详细解释:
1.notify():
锁对象.notify(), 会唤醒在锁对象上因调用 wait()
方法而处于等待状态的线程。
在 Java 中,每个对象都有一个与之关联的监视器(monitor),也可理解为一把锁,同时还有一个等待队列。当一个线程调用对象的 wait()
方法时,该线程会释放持有的对象锁,并进入该对象的等待队列中进入等待状态。而 notify()
方法就是专门用于操作这个等待队列的,当调用 锁对象.notify()
时,JVM 会从锁对象的等待队列中随机选择一个处于等待状态的线程,将其从等待状态唤醒。(上面代码的锁对象locker只有一个线程,所以直接唤醒了)
2.notifyAll():
唤醒所有在当前对象上因调用 wait()
方法而处于等待状态的线程。这些被唤醒的线程会竞争该对象的锁,获得锁的线程才能继续执行 wait()
方法之后的代码,其他线程则继续等待锁的释放(少用)。