目录
一、线程不安全的原因
1. 线程是抢占式执行的,线程间的调度充满的随机性。
2. 修改共享数据
3. 原子性:针对变量的操作不是原子的
解决方法:synchronized 加锁
4. 内存可见性
解决方法:synchronized 和 volatile
5. 指令重排序
解决方法:synchronized
二、synchronized 关键字 —— 监视器锁 monitor lock
1. synchronized 的特性
(1)互斥
(2)刷新内存(保证了内存可见性)
(3)可重入
2. synchronized 的使用方式:
(1)修饰一个普通方法
(2)修饰一个代码块
(3)修饰一个静态方法
3. Java 标准库中线程安全的类
三、volatile 关键字
1. JMM (Java Memory Model)(Java 内存模型)
2. volatile 和 synchronized
四、wait 方法 和 notify 方法
1. wait( ) 方法
2. notify( ) 方法
3. 基本用法
4. notifyAll( ) 方法
多个线程修改同一个共享数据救护出现线程不安全的情况。(若多个线程读同一个数据 或者 修改各自的数据 则不会出现线程不安全的情况)
以 count++ 这条语句举例,在计算机内部,这条操作分为了三个CPU指令:①load:把内存中的 count 的值,加载到 CPU 寄存器中;②add:把寄存器中的值 + 1;③save:把寄存器的值写回到 内存 的 count 中。
在多线程情况下,是抢占式执行的。假设有两个线程,当两个线程“抢占式执行”,就导致了两个线程同时执行这三条指令时,顺序上充满了随机性。
当两个线程同时对 count++ 时,会出现当线程一还没将它寄存器中计算好的值放回内存时,线程二就将内存中的 count 值拿走了。假设 count = 0,使用线程一和线程二同时对 count++,预期结果是 count = 2,但是如果是上述情况的话,线程一拿走 0 到他的寄存器进行计算得 1,还没将 1 返回到内存中,线程二就像 内存 中 count 的值 0 拿到了他的寄存器中,然后再进行计算得 1,最后两个线程将其寄存器的值返回到内存中 的 count,结果是 1。明明加了两次,但结果还是 1。因此出现了线程不安全的情况。
public class Demo8 {
//对同一个变量count进行 ++ 操作,使用两个线程同时加,预期正常结果为 10_0000
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
count++;
}
});
t1.start();
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
count++;
}
});
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
可见是线程不安全的。
在自增之前,先加锁,lock;在自增之后,再解锁,unlock;
因为在实际开发中,一个线程中有很多任务。在这些任务中,可能只有任务4是线程不安全的,所以只对任务4进行加锁即可,而上面的任务1、任务2、任务3都是并发执行的。
加锁的方法之一:synchronized 关键字。
给方法加上 synchronized 关键字,此时进入方法时,就会自动加锁,离开方法,就会自动解锁。当一个线程加锁成功时,其他线程尝试加锁,就会触发阻塞等待(此时对应的线程就处于 BLOCKED 状态)。阻塞会一直持续到占用锁的线程把锁释放为止。
class Counter{
public int count;
synchronized public void increase(){
count++;
}
}
public class Demo10 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
counter.increase();
}
});
t1.start();
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
counter.increase();
}
});
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
假设针对同一个变量,一个线程 t1 进行读操作(循环进行很多次),一个线程 t2 进行修改操作(合适的时候进行一次)。
t1 线程在循环读这个变量,这个变量在内存中。因为读取内存操作相比于读取寄存器操作来说要慢很多(慢 3 ~ 4 个人数量级),而此时 t2 右迟迟不进行修改,导致 t1 每次读到的数值都是同一个数值。因此就出现了Java编译器进行的代码优化,即不再从内存读数据,而是直接从寄存器里读值。一旦 t1 这样做,万一此时 t2 进行了修改,t1 就不能知道了。因此不是内存可见的。
如下面代码所示,t 线程一直在飞速运转读取 isQuit 的值进行判断,经过优化后,t 线程直接在寄存器中读取了。而当我们输入isQuit 的值,isQuit 的值不再为 0 了,对应的 t 线程中循环判断条件为 false而退出循环,进而打印 “循环结束,t 线程退出”。但实际上并没有,t 线程仍然在运行中,则出现了线程不安全的情况。
public class Demo9 {
public static int isQuit = 0;
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (isQuit == 0) {
}
System.out.println("循环结束,t线程退出");
});
t.start();
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个 isQuit 的值:");
isQuit = scanner.nextInt();
System.out.println("main 线程执行完毕");
}
}
(1)使用 synchronized 关键字
synchronized 关键字不光能保证指令的 原子性,同时也能保证 内存可见性。
被 synchronized 包裹起来的代码,编译器就不敢轻易做出上述的假设(优化),相当于手动禁止了编译器的优化。
(2)使用 volatile 关键字
volatile 和 原子性 无关,但是能够保证 内存可见性。
禁止编译器做出上面优化,编译器每次执行 判定相等,都会重新从 内存 中读取 isQuit 的值。
public class Demo9 {
public static volatile int isQuit = 0;
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (isQuit == 0) {
}
System.out.println("循环结束,t线程退出");
});
t.start();
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个 isQuit 的值:");
isQuit = scanner.nextInt();
System.out.println("main 线程执行完毕");
}
}
指令重排序也会影响到线程安全问题。指令重排序也是编译器优化中的一种操作。
对于我们平常写的代码,谁在前,谁在后无所谓,但是编译器不这样认为。编译器会智能的整理这些代码的前后顺序,从而提高程序的效率。(保证逻辑不变的前提下,去调整)
对于单线程而言,编译器的判定是很准的;而对于多线程而言,编译器可能会产生误判。
synchronized 不光能保证原子性,同时还能够保证内存可见性,同时还能禁止指令重排序。
解决线程不安全要从三个方面考虑:原子性、内存可见性、指令重排序。
使用 synchronized 时,本质是哪个是针对某个对象进行加锁。而在代码的异常信息中,可能会出现 monitor lock 这个词。
加锁操作是指在对象(实例)的 对象头 里 设置了一个标志位。在Java中,每个类都是继承自Object类。每个 new 出来的实例,里面一方面包含了你自己安排的属性,一方面包含了“对象头”,这个对象头中存储的是对象的一些 元数据 。
使用 synchronized 会产生互斥的效果。进入 synchronized 修饰的代码块时,自动加锁,退出 synchronized 修饰的代码块时,自动解锁。此时其他的线程才有可能获得锁。
使用时要注意:多个线程要针对同一个锁对象进行加锁才有用。
当一个线程获得了锁之后,它的大致执行流程:
将一系列操作进行了捆绑,实现了内存可见性。
synchronized 实现的锁为 可重入锁,即 不会自己把自己锁死。
当一个线程还没有释放锁,然后又尝试获取锁时,会出现如下情况:第一次获取锁成功,第二次获取锁时,锁还没有被释放,则该线程就会一直处于阻塞状态,直到锁被释放。但是此时 锁 已经在该线程中,释放锁也必须由该线程完成,但是此时该线程处于阻塞状态,就会造成 死锁 问题。
死锁 的 四个 必要条件:
互斥使用:
一个锁被一个线程占用了之后,其他线程占用不了。
不可抢占:
一个锁被一个线程占用了之后,其他线程不能把这个锁给抢走。
请求和保持 :
当一个线程占用了多把锁之后,除非显示的释放锁,否则这些锁始终都是被该线程持有。
环路等待:
等待关系,成环了。(A 等 B,B 等 C,C 又等 A)
而对于 synchronized 来说,不会出现这样的现象。在第二次尝试获取锁的时候又加了一次锁,相当于第一次锁没有释放,然后加了两次锁。
在可重入锁的内部,包含了 “线程持有者” 和 “计数器” 两个信息。此时有两种情况:
此时锁对象是 this 。
例如上面的例子中:这里的这里的 synchronized 就是针对 this 来加锁,加锁的位置就是在设置 this 的对象头的标志位。
class Counter {
public static int count;
synchronized public static void increase() {
count++;
}
}
要显示指定针对哪个对象加锁,Java中的任意一个对象都可以作为锁对象。
例如:
class Counter {
public static int count;
public void increase() {
synchronized (this){
count++;
}
}
}
对当先类的 类对象 加锁。以上面的例子来说,锁对象为 Counter.class,即 类名.class 。
类对象:在运行程序中, .class 文件被加载到JVM内存中的模样。——> 反射机制。
所谓的 “静态方法” 即 “类方法” ,而普通的方法为 “实例方法”。而静态方法中是没有对象实例的。因此锁对象为类对象。
例如:
class Demo {
synchronized public static void fun() {
System.out.println("111");
}
}
等价于
class Demo {
public static void fun() {
synchronized (Demo.class) {
System.out.println("111");
}
}
}
Java 有很多现成的类,有些是线程安全的,有些是线程不安全的。因此在多线程环境下,如果使用线程不安全的类,就需要小心谨慎。
线程不安全的类:
线程安全的类:这些关键方法上都有 synchronized,可以保证在多线程下,修改同一个对象没有问题。
volatile 主要是阻止编译器优化,保存内存可见性。(例如在频繁读一个值时,依旧每次都从 内存 中读取)。不保证原子性。
JMM就是将硬件结构,在Java中用专门的术语又重新抽象的封装了一遍。-> 主内存(内存)和工作内存(CPU、寄存器、缓存... 统称为工作内存)。
因为Java是一个跨平台的变成语言,因此希望程序员在使用时,感知不到 CPU、内存等硬件设备的存在,所以要把硬件的细节封装起来。(假设某个计算机没有CPU,或没有内存,同样可以套在该模型中)
volatile 只保证 内存可见性,不保证原子性。只处理一个线程读,一个线程写的情况。
synchronized 都能处理。(原子性,内存可见性,指令重排序)
因为线程之间是抢占式执行的,充满了随机性。而实际开发中,我们需要让线程按照一定的顺序执行,因此可以使用 wait(等待) 和 notify(唤醒)。
join 也是一种控制循环的方式,它更倾向于控制线程的结束。
wait 和 notify 都是 Object 类的方法。调用 wait 方法的线程会陷入阻塞,阻塞到有其他线程通过 notify 来通知。
调用 wait 方法后,内部会做三件事:
可见要调用 wait 方法,前提是已经获取到锁了。即要搭配 synchronized 来进行使用。(notify 也要搭配 synchronized 来使用)
例如: wait 哪个对象,就要对哪个对象加锁。
public class Demo2 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object) {
System.out.println("wait 前");
//代码中调用了 wait 就会发生阻塞
object.wait();
System.out.println("wait 后");
}
}
}
notify 方法是唤醒等待的线程。搭配 wait 方法(和 synchronized)使用。
如下例,有两个线程,让第一个线程调用 wait 方法,第二个线程调用 notify 方法,观察打印结果。
public class Demo3 {
private static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized(locker) {
System.out.println("wait 前");
try {
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait 后");
}
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
synchronized(locker) {
System.out.println("notify 前");
locker.notify();
System.out.println("notify 后");
}
});
t2.start();
}
}
由上面例子可以看出,在 t1 线程调用 wait 后,t1 线程进入了阻塞状态。然后 main 线程休眠了1s后,开始执行 t2 线程,即开始调用 notify 方法,调用之后,t1 线程阻塞状态结束,继续执行代码,因此打印了 “wait 后”。
如图,假设有两个线程(t1 和 t2),t1 里的任务:a、b、c, t2 里的任务:e、f、g。假设我们需要让两个线程按照:a -> e ; b -> f;c -> g 的顺序执行。
wait 和 notify 都是针对同一个对象来操作,而notifyAll 可以一次性唤醒所有的等待线程。而所有唤醒的线程之间仍然需要竞争锁。
假设现在有一个对象 o ,并且有 10 个线程,都调用了 o. wait ,此时 10 个线程都是阻塞状态。
如果调用了 o.notify ,就会把 10 个其中的一个给唤醒。(唤醒哪个,不确定)
如果调用了 o.notifyAll,就会把所有的10个线程都唤醒,wait 唤醒后,会重新尝试获取锁(产生竞争)