目录
·前言
一、 synchronized 关键字
1. synchronized 的作用
1. synchronized 的特性
(1)互斥性
(2)可重入
2. synchronized 使用示例
(1)修饰代码块
(2)直接修饰普通方法
(3)修饰静态方法
3. Java 标准库中线程安全类
二、 volatile 关键字
1.内存可见性问题
2.指令重排序问题
·结尾
·前言
在开始本篇文章分享之前,先简单回顾一下上一篇文章的内容,上一篇文章介绍了线程安全这一话题,列举了一个代码示例来演示线程不安全的样子,然后通过分析代码示例来引出了产生线程安全问题的原因,最后使用了 synchronized 关键字来解决了示例代码的问题,本篇文章就来针对在 Java 中解决线程安全问题两个重要的关键字 synchronized 关键字与 volatile 关键字进行详细的介绍。
一、 synchronized 关键字
1. synchronized 的作用
使用 synchronized 关键字主要是为了保证线程安全,至于他如何保证线程安全,这就与他的特性有关了,使用 synchronized 关键字属于加锁操作,在 Java 中还有很多加锁方式,但是使用 synchronized 关键字加锁是最主要的方式。
1. synchronized 的特性
(1)互斥性
synchronized 会起到互斥效果,如果一个线程针对一个对象加上锁之后,其他线程再尝试对这个对象加锁就会产生阻塞,这也就是锁竞争,此时这个被阻塞的线程状态就会变成 BLOCKED 一直阻塞到前一个线程释放锁为止。如果两个线程是分别对不同的对象加锁,此时也就不会产生锁竞争,就不会有阻塞。
- 进入 synchronized 修饰的代码块,相当于加锁;
- 退出 synchronized 修饰的代码块,相当于解锁;
(2)可重入
使用 synchronized 关键字所加的锁是可重入锁,可重入锁就避免了自己把自己锁死这样的问题,可以这样理解,在一个线程中,对同一个对象加锁两次如下图所示:
此时就会出现“死锁问题”,但是由于使用 synchronized 加锁属于可重入锁,也就避免了上面的情况,那么他是如何避免的呢? 对于可重入锁来说,它的内部会持有两个信息:
- 当前这个锁是哪个线程持有的
- 记录加锁次数的计数器
还是上面的代码,可重入锁的执行如下图所示:
在上述对同一把锁进行第二次加锁时,会先判断当前加锁的线程是否是持有锁的线程,如果不是同一个线程,那么就会进行阻塞,如果是同一个线程就只会进行计数器+1的操作,没有其他的操作了。
2. synchronized 使用示例
在使用 synchronized 关键字进行加锁操作时,首先需要准备好“锁对象”,加锁解锁的操作都是依托于这里的“锁对象”来进行展开的,在 Java 中,任何一个对象都可以作为“锁对象”,可以大致认为 Java 中每个对象在内存中存储时,都有一块内存,表示当前“锁定”状态,这样,作为锁对象的对象,在当一个线程中代码执行到 synchronized 修饰的代码块时,这个对象的”锁定“状态就变为”加锁“状态,此时其他线程在想对这个对象加锁就会产生阻塞(为方便理解做出的假设)。
(1)修饰代码块
修饰代码块就如下代码使用的加锁方式:
public class SynchronizedDemo {
// 创建锁对象
private static Object locker = new Object();
public static void main(String[] args) {
Thread t = new Thread(()->{
// 修饰代码块,进行加锁
synchronized (locker) {
System.out.println("hello thread!");
}
});
}
}
上述这种,属于锁定任意对象,因为这里 synchronized 所使用的锁对象是任意的,下面再演示一下锁定当前对象的用法,这里用一个示例代码,使用 synchronized 关键字来保证两个线程对变量 count 进行自增操作的线程安全,代码及运行结果如下:
class Test{
public int count = 0;
public void add() {
// 使用 this 作为锁对象,是指锁定当前对象
synchronized (this) {
count++;
}
}
}
public class ThreadDemo13 {
public static void main(String[] args) throws InterruptedException {
Test t = new Test();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
t.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
t.add();
}
});
// 启动线程 t1 和 t2
t1.start();
t2.start();
// 等待线程 t1 和 t2 工作完成
t1.join();
t2.join();
// 打印 count 的值
System.out.println("count = " + t.count);
}
}
如上面示例代码可以发现,锁对象是 this,这就相当于锁定了当前 this 所引用的对象,代码中两个线程都使用创建出的对象 t 来调用 add 方法,所以对于这两个线程来说,this 指向的是同一个对象,就会产生锁竞争,就会产生阻塞,进而就可以保证线程安全。
(2)直接修饰普通方法
使用 synchronized 直接修饰普通方法,相当于锁的是当前类实例化出来的对象,如下图所示:
这两个代码本质上都是对 this 进行加锁,也就是当前对象,两个线程在调用 add 方法时,是使用同一个对象来调用才会发生锁竞争,如果不使用同一个对象调用 add 方法就不会产生锁竞争,也就无法保证线程安全。
(3)修饰静态方法
使用 synchronized 修饰静态方法,相当于锁的是类对象,如下图所示:
Test.class 这是获取类对象,一个类,类对象只有一个,所以当多个线程同时调用 func 方法时,就会产生锁竞争,直接修饰静态方法效果也是一样,静态方法可以直接使用类名来调用,所以本质也是对类对象进行加锁。
3. Java 标准库中线程安全类
在 Java 标准库中有很多都是线程不安全的,这是因为这些类可能会涉及到多线程修改共享数据(这是产生线程安全问题的原因之一),又没有添加任何锁措施,比如:
ArrayList、HashMap、TreeMap、TreeMap、HashSet、TreSet、StringBuider……
虽然说上面这些类是线程不安全的,但也不是说在写多线程代码涉及多个线程尝试修改上述类实例化的同一个对象就 100% 出现问题,只是说容易出现线程安全问题,至于会不会出现也要具体代码具体分析。
当然,在 Java 标准库中也有一些是线程安全的类,这些类中都使用了一些锁机制来控制预防产生线程安全问题,比如:
ConcurrentHashMap、StringBuffer……
这里以 StringBuffer 为例,如下图所示: 在 StringBuffer 中的一些关键方法上都使用 synchronized 关键字进行修饰了,所以在一定程度上保证了使用 StringBuffer 的线程安全。同样,使用这些类也不能保证 100% 不出现线程安全问题,也是需要具体代码具体分析。
在这里,还有一个类虽然没有加锁,但是也是线程安全的,就是 String ,由于 String 实例化出来的对象不能被修改,所以可以保证线程安全。
二、 volatile 关键字
1.内存可见性问题
如果我们写一个代码,使用一个线程对变量进行修改,一个线程去读取这个变量,此时会不会产生线程安全问题呢?其实也是可能会产生线程安全问题的,以下面代码为例,代码及运行结果如下所示:
import java.util.Scanner;
public class VolatileDemo {
private static int flag = 0;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
Thread t1 = new Thread(()->{
while (flag == 0){
// 循环中什么都不写
}
System.out.println("t1 线程结束");
});
Thread t2 = new Thread(()->{
System.out.println("请输入 flag 的值:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
上述代码主要是想用线程 t2 来控制线程 t1 的结束,可是当我们输入非 0 的值的时候,发现线程 t1 并没有结束,这显然是出现了问题,至于为什么出现这样的问题,这里我可以先给出一个原因,就是因为线程 t2 修改了内存,但是线程 t1 并没有看到这个内存的变化,这也就是“内存可见性问题”。
那么为什么会出现这样的问题呢?在上述代码执行过程中,由于线程 t2 要等待用户的输入,所以无论 t1 先启动还是 t2 先启动,等待用户输入的过程中,线程 t1 必然是已经循环了很多次了,线程 t1 这里涉及到代码的核心指令有以下两条:
- load 读取内存中 flag 的值到 CPU 的寄存器中;
- 拿着 CPU 寄存器中的值和 0 进行比较。
由于 t1 循环执行的速度非常快,所以就会产生反复的执行上述指令 1 和 2 ,在这个执行过程中又有两个关键要点:
- load 操作每次执行的结果都是一样的,这是因为在我修改 flag 是过了几秒后才修改的,在此期间,load 已经执行了上亿次;
- load 操作的开销远远大于比较操作(访问寄存器的操作速度远远超过访问内存)。
频繁执行 load 和比较操作,其中执行 load 开销大,并且 load 的结果又没有变化(出现变化要等几秒后), 此时,JVM 就会认为我们这里的 load 操作没有存在的必要,就会做出代码优化,把上述 load 操作优化掉(只有前几次进行 load 操作,后面发现 load 获取的值是一样的,所以就直接把 load 操作给优化掉了),优化掉 load 操作后,就不会再重复读内存了,而是直接使用之前 load 操作读到 CPU 寄存器中“缓存”的值了,这样就会提高循环的执行速度。
“内存可见性问题”是高度依赖编译器的优化来实现的,当然,编译器什么时候触发优化,什么时候不触发优化,并不容易观察,对于上述代码来说,只需要改动一点,结果就会截然不同,所以解决上述问题方法也很简单,我们可以直接在 t1 线程的循环中加上 sleep 方法,具体代码及运行结果如下所示:
import java.util.Scanner;
public class VolatileDemo {
private static int flag = 0;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
Thread t1 = new Thread(()->{
while (flag == 0){
// 循环中什么都不写
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t1 线程结束");
});
Thread t2 = new Thread(()->{
System.out.println("请输入 flag 的值:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
上述代码可以正确运行结束,主要是因为加了 sleep ,这使原本一秒可以进行上百亿次的循环一下降到一秒循环 1000 次了,这是 load 操作的整体开销没有那么大了,所以优化的迫切程度就降低了,就不会进行优化了。
修改后的代码虽然可以正常工作了,但是我们更希望让我们的代码确保无论当前代码怎么写都不要出现“内存可见性问题”,所以 Java 就提供了 volatile 关键字,volatile 关键字可以使上述的优化强制关闭,可以确保每次循环都重新从内存中读取数据。使用 volatile 关键字进行解决上述代码的“内存可见性问题”代码及运行结果如下:
import java.util.Scanner;
public class VolatileDemo {
// 使用 volatile 关键字进行修饰变量 flag 迫使每次读 flag 变量都要从内存中读
private static volatile int flag = 0;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
Thread t1 = new Thread(()->{
while (flag == 0){
// 循环中什么都不写
}
System.out.println("t1 线程结束");
});
Thread t2 = new Thread(()->{
System.out.println("请输入 flag 的值:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
此时,代码也可以正确的运行。
2.指令重排序问题
出现指令重排序问题也与 JVM 的优化有关,这个问题不容易用代码进行演示,所以可以举一个生活中的例子来进行说明,如下:
假设目前我们执行的一段代码顺序是这样的:
- 去宿舍楼下取外卖;
- 回宿舍写作业;
- 去宿舍楼下卖水。
上述逻辑在我们的 JVM 上会对流程进行一个优化,比如按 1->3->2 的顺序执行也是没有问题的,并且可以少下一次楼,这就叫做指令重排序,我们编译器在对于指令重排序的前提是“保持原有的逻辑不发生变化”,这一点在单线程环境下比较容易判断,但是在我们多线程环境下就没那么容易判断了,所以多线程中 JVM 对我们的代码进行指令重排序时就可能出现优化后的逻辑与之前不等价的情况,此时我们仍然可以使用 volatile 关键字来阻止 JVM 对代码进行指令重排序。
·结尾
文章到此也就要结束了,通过使用 synchronized 关键字和 volatile 关键字可以解决线程安全的问题,当然解决线程安全问题也未必一定要使用上述两个关键字,希望通过对这两个关键字的介绍,能让大家更好的理解这两个关键字,这两个关键字在后续文章中会经常出现用来保证多线程代码的线程安全,如果对本篇文章的知识有不明白的地方欢迎在评论区进行留言,同样,如果感觉本篇文章还不错的话,留下您宝贵的三连吧,我们下一篇文章再见~~~