1、重学 Java 基础之线程基础(二)
1.1、volatile
1.1.1、基础概念
Java 内存模型规定了所有的变量都存储在主内存中,此处的主内存仅仅是虚拟机内存的一部分,而虚拟机内存也仅仅是计算机物理内存的一部分(为虚拟机进程分配的那一部分)。
Java 内存模型分为主内存,和工作内存。主内存是所有的线程所共享的,工作内存是每个线程自己有一个,不是共享的。
每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取、赋值),都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
read-load 从主内存复制变量到当前工作内存,use-assign 执行代码改变共享变量值,store-write 用工作内存数据刷新主存相关内容。
Java通过几种原子操作完成工作内存和主内存的交互:
- lock:作用于主内存,把变量标识为线程独占状态。
- unlock:作用于主内存,解除独占状态。
- read:作用主内存,把一个变量的值从主内存传输到线程的工作内存。
- load:作用于工作内存,把read操作传过来的变量值放入工作内存的变量副本中。
- use:作用工作内存,把工作内存当中的一个变量值传给执行引擎。
- assign:作用工作内存,把一个从执行引擎接收到的值赋值给工作内存的变量。
- store:作用于工作内存的变量,把工作内存的一个变量的值传送到主内存中。
- write:作用于主内存的变量,把store操作传来的变量的值放入主内存的变量中。
由 Java 内存模型来直接保证的原子性变量操作包括 read 、load 、use 、assign 、store 和 write 六个,大致可以认为基础数据类型的访问和读写是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 与 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐匿地使用这两个操作,这两个字节码指令反映到 Java 代码中就是同步块--- synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。
JVM对交互指令的约束
如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。
Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是 read 和 load 之间,store 和 write 之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 不允许 read 和 load 、store 和 write 操作之一单独出现
- 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化( load 或 assign )的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
- 一个变量在同一时刻只允许一条线程对其进行 lock 操作,lock 和 unlock 必须成对出现
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。
1.1.2、作用
保证内存可见性,所有线程都能看到共享内存的最新状态。
volatile 的特殊规则就是:
- read、load、use 动作必须连续出现。
- assign、store、write 动作必须连续出现。
所以,使用 volatile 变量能够保证:
- 每次读取前必须先从主内存刷新最新的值。
- 每次写入后必须立即同步回主内存当中。
防止指令重排
指令重排序是 JVM 为了优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。指令重排序包括编译器重排序和运行时重排序。
经典问题就是懒加载单例模式与 DCL(Double Check Lock,双重检查锁)机制。
volatile如何防止指令重排
volatile 关键字通过“内存屏障”来防止指令被重排序。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java 内存模型采取保守策略。
下面是基于保守策略的JMM内存屏障插入策略:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障
1.1.3、volatile 陷阱
volatile 使得对变量的读写有着相关的“原子性”,但是对于变量值的变化由自己本身的值引起,那么就不是原子性的。如果是由其他变量值引起的原子性就有效。比如:
代码语言:text复制# 非原子性
public static volatile int n = 0;
n = n 1;
n ;
# 原子性
n = m 1
其实 volatile 并不保证所有情况都不进行重排序,像下面两种情况,是允许指令重排序的:
1、普通变量的读/写操作,然后 volatile 变量的读操作
2、volatile 变量的写操作,然后普通变量的读/写操作
1.2、多线程会导致的问题以及如何解决
如果线程操作不当,可能会引起死锁。
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放,而该资源又被其他线程锁定,从而导致每一个线程都得等其它线程释放其锁定的资源,造成了所有线程都无法正常结束。
产生死锁的必要条件:
- 1、互斥条件:即当资源被一个线程使用(占有)时,别的线程不能使用。
- 2、不可剥夺条件:资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
- 3、请求和保持条件:线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他线程占有,此时请求线程被阻塞,但对自己已获得的资源保持不放。
- 4、循环等待条件:存在一种线程资源的循环等待链,链中每一个线程已获得的资源同时被链中下一个线程所请求。
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。
1.3、线程通信
有时候我们需要多个线程进行协作完成任务,既然是合作,那么肯定少不了线程之间的通信了。我们来尝试一下多个线程间如何协作完成任务。
现在有一个需求,就是需要两个线程,对一个集合进行数据读取。要求两个线程需要进行交叉读取,比如A线程读了第一个元素,B线程就需要读取第二个元素。
最开始的尝试:
代码语言:c#复制public class TestThread {
public static List<Integer> numList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
public static int point = 0;
public static void main(String[] args) {
Thread1 thread1 = new Thread1();
Thread2 thread2 = new Thread2();
thread1.start();
thread2.start();
}
public static class Thread1 extends Thread {
@Override
public void run() {
for (int i = 0; i < 5; i ) {
try {
System.out.println(String.format("%s:%d",Thread.currentThread().getName(),TestThread.numList.get(TestThread.point)));
TestThread.point ;
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static class Thread2 extends Thread {
@Override
public void run() {
for (int i = 0; i < 5; i ) {
try {
System.out.println(String.format("%s:%d",Thread.currentThread().getName(),TestThread.numList.get(TestThread.point)));
TestThread.point ;
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
# 结果
Thread-0:1
Thread-1:1
Thread-0:3
Thread-1:3
Thread-1:5
Thread-0:5
Thread-1:7
Thread-0:7
Thread-1:9
Thread-0:9
按理来说是两个线程读取的数据是不重复的,但是我们发现两个线程读取的数据重复了,因为我们递增操作没有同步。我们进行下一步尝试:
代码语言:scala复制 public static class Thread1 extends Thread {
@Override
public void run() {
for (int i = 0; i < 5; i ) {
try {
synchronized(TestThread.class){
System.out.println(String.format("%s:%d",Thread.currentThread().getName(),TestThread.numList.get(TestThread.point)));
TestThread.point ;
Thread.sleep(500);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
# Thread2修改部分一样
# 结果
Thread-0:1
Thread-0:2
Thread-0:3
Thread-1:4
Thread-0:5
Thread-0:6
Thread-1:7
Thread-1:8
Thread-1:9
Thread-1:10
数字是连续了,但是线程没有交叉进行。
然后我们再次进行尝试:
代码语言:text复制public class TestThread {
public static List<Integer> numList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
public static int point = 0;
public static boolean read = false;
public static void main(String[] args) {
Thread1 thread1 = new Thread1();
Thread2 thread2 = new Thread2();
thread1.start();
thread2.start();
}
public static class Thread1 extends Thread {
@Override
public void run() {
for (int i = 0; i < 5; i ) {
try {
synchronized(TestThread.class){
if (!TestThread.read){
TestThread.read = true;
TestThread.class.notify();
}
System.out.println(String.format("%s:%d",Thread.currentThread().getName(),TestThread.numList.get(TestThread.point)));
TestThread.point ;
Thread.sleep(500);
if (TestThread.point == 10){
TestThread.class.notifyAll();
}else{
TestThread.class.wait();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static class Thread2 extends Thread {
@Override
public void run() {
for (int i = 0; i < 5; i ) {
try {
synchronized(TestThread.class){
if (TestThread.read){
TestThread.read = false;
TestThread.class.notify();
}
System.out.println(String.format("%s:%d",Thread.currentThread().getName(),TestThread.numList.get(TestThread.point)));
TestThread.point ;
Thread.sleep(500);
if (TestThread.point == 10){
TestThread.class.notifyAll();
}else{
TestThread.class.wait();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
# 打印
Thread-0:1
Thread-1:2
Thread-0:3
Thread-1:4
Thread-0:5
Thread-1:6
Thread-0:7
Thread-1:8
Thread-0:9
Thread-1:10
ok,现在是符合我们的需求了。我们来分析一下这段代码:
1、为什么 synchronized 锁的不是this?
如果锁的是 this 的话,线程 A 锁的是 Thread1 的实例,线程 B 锁的是 Thread2 的实例,两个线程在获取锁对象(对象监视器)的时候没有任何阻碍,自然也就没有同步效应了。而锁 TestThread.class ,A 线程访问同步方法就会获得同步锁,B 线程访问线程方法时就会因为获取不到同步锁而被阻塞,这时候自然就变同步了。
2、为什么要使用 TestThread.class.wait() ?
notify 和 wait 需要在同步方法中执行,不然会抛出 IllegalMonitorStateException 异常。不正确的释放锁也会抛出 IllegalMonitorStateException 异常,比如没有锁定某个对象却使用 wait 去释放锁就会抛出异常。wait 等方法是由对象锁去调用,锁的是谁,谁就可以去释放锁。
3、为什么要在最后放置 TestThread.class.wait()?
继上一个问题,同步之后,每个线程在执行完一次循环后,会因为各种因素导致线程执行的优先级不一样,进而导致可能会出现一个线程执行好几次的问题,所以在后面放置一个 wait 让线程执行完一次循环后都停止下来。
4、为什么需要添加 TestThread.class.notifyAll()?
因为这段代码不管谁执行到最后,都会执行 wait 方法,然后最后一个线程会暂停,这时已经没有线程去执行唤醒操作了,如果不添加 notifyAll 判断,这个程序会一直暂停下去,当然是用 notify 也行,虽然 notify 是随机唤醒一个 wait 中的线程,但是因为线程只有两个,一个线程因为之前就已经被唤醒了,所以使用 notify 必定唤醒仅存的一个线程。notifyAll 是唤醒所有 wait 中的线程。
5、为什么结果能保证交叉执行?
TestThread.class.notify() 之后不是会随机唤醒线程并且释放锁吗?释放锁之后 notify 之后的代码不就不是同步的了吗,这样不是很容易线程永久阻塞?这时候就要说说 notify 释放锁的机制了。notify 是会释放锁,但不是立刻释放锁,是等待执行完同步块代码后再释放锁(这个结论我就不测试了),这样就保证了当前线程的 wait 的执行。
还有个带参数的 wait(long),这个方法是如果在指定时间内有线程对其唤醒,就直接唤醒,如果没有则会自动唤醒。单位是ms。
interrupt 可以终止 wait 吗
我们之前不是学了一个 interrupt 方法吗?在线程 wait 的时候调用线程的 interrupt 方法会终止线程吗?答案是会的。
代码语言:java复制public class TestThread {
public static void main(String[] args) {
Thread1 thread1 = new Thread1();
thread1.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread1.interrupt();
}
public static class Thread1 extends Thread {
@Override
public void run() {
synchronized (this){
try {
System.out.println("我开始运行了");
this.wait();
System.out.println("我结束了");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
1S后 thread1 线程会抛出一个 InterruptedException 异常。
interrupt 可以终止阻塞中的线程吗?
interrupt 可以终止阻塞中的线程吗?答案是 interrupt 只能终止非阻塞下的线程。
代码语言:java复制public class TestThread {
public static void main(String[] args) {
Thread1 thread1 = new Thread1();
Thread1 thread2 = new Thread1();
thread1.start();
thread2.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread2.interrupt();
System.out.println("执行中断");
}
public static class Thread1 extends Thread {
@Override
public void run() {
synchronized (TestThread.class){
try {
System.out.println(Thread.currentThread().getName());
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
如果 interrupt 可以终止阻塞的线程,那么在执行 thread2.interrupt(); 的时候就会抛出个异常然后直接结束,但实际上不会,异常还是照样抛出,只不过在执行完 interrupt 后,等待线程 1 执行完后,线程 2 才抛出异常。想要终止阻塞线程,必须使用 wait 方法去释放锁,这样阻塞线程才可能有机会获取锁而继续执行下去。
1.4、join
后续补充……
1.5、ThreadLocal
ThreadLocal,让每个线程都拥有自己的 “小秘密” 。每个线程 ThreadLocal 数据相对于每个线程都是隔离的。
代码语言:text复制public class TestThread {
public static ThreadLocal<Integer> local = new ThreadLocal<>();
public static void main(String[] args) {
Thread thread = new Thread(() -> {
String name = Thread.currentThread().getName();
System.out.println(name "设置值666");
local.set(666);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(name local.get());
});
Thread thread1 = new Thread(() -> {
String name = Thread.currentThread().getName();
System.out.println(name "设置值777");
local.set(777);
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(name local.get());
});
thread.start();
thread1.start();
}
}
#打印
Thread-0设置值666
Thread-1设置值777
Thread-0666
Thread-1777
线程 1 设置值后休眠 2 秒,这时候线程 2 执行了设置值,虽然local是全局的静态变量,但是在线程中打印出来的变量还是自己之前设置的值,没有被覆盖。
怎么设置ThreadLocal的默认值呢?
代码语言:typescript复制public class TestThread {
public static ThreadLocal local = new TestThreadLocal();
public static void main(String[] args) {
System.out.println(local.get());
}
public static class TestThreadLocal extends ThreadLocal{
@Override
protected Object initialValue() {
return "688";
}
}
}
继承 ThreadLocal 然后重写 initialValue 方法,就能把 null 的默认值给覆盖掉。
我正在参与2023腾讯技术创作特训营第三期有奖征文,组队打卡瓜分大奖!