找往期文章包括但不限于本期文章中不懂的知识点:
目录
初始JavaEE篇——多线程(2):join的用法、线程安全问题-CSDN博客
上文我们学习了 多线程的线程安全问题以及解决方法。下面我们针对加锁操作来继续深入学习。
重复加锁——可重入锁
针对 count++ 操作不是原子性,我们将其进行了加锁的操作,让其可以在执行时,不受操作系统调度的影响(即使调度了别的线程,也不能够进行count++ 操作)。虽然加锁是一个好办法,但是如果我们重复加锁呢?又会出现什么样的问题呢?
代码演示:
public class Test {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()->{
synchronized (locker) {
for (int i = 0; i < 100000; i++) {
synchronized (locker) {
count++;
}
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker) {
for (int i = 0; i < 100000; i++) {
synchronized (locker) {
count++;
}
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
代码分析:
虽然我们上面分析的是对的,但代码运行的结果却是正确答案,是因为 在 Java 中,对于同一个对象的锁(使用synchronized关键字加锁的对象),当一个线程已经获取了该锁之后,再次对这个对象进行加锁操作,不会触发阻塞,而是直接继续执行。这是因为 Java 的锁是可重入锁。
可重入锁的主要目的是为了避免死锁(上面分析出的死循环)和保证线程安全的情况下允许嵌套调用。例如,一个方法内部调用了另一个同步方法(被synchronized关键字修饰的方法),而这两个方法都使用了同一个对象的锁,如果不是可重入锁,那么在内部调用时就会发生死锁,因为线程已经持有了锁但又无法再次获取锁。而如果是其他线程尝试获取这个已经被某个线程持有的锁,就会正常阻塞,直到持有锁的线程释放锁。这是保证线程安全的基本机制,确保在同一时刻只有一个线程能够访问被锁保护的临界区。
可重入锁:一个线程针对一把锁进行重复加锁的操作,可以执行成功,不会阻塞等待。
实现原理:当有一个线程先拿到这把锁时,这把锁就会保留这个这个线程的信息,当下一次有线程继续进行加锁操作时,这个锁便会去检查是不是保留的这个线程,如果是的话,就会让其加锁成功,继续执行下去;反之,则会进行阻塞等待。
而针对多层加锁操作,什么时候解锁呢?用一个计数器来记录当前遇到的 {} 的数量。是 { ,就++,是 } 就 --,当 计数器为0时,就是解锁的时候了。
死锁
现象
接下来,我们就看真正的死锁。当线程1拿到锁1之后,另线程2也拿到了锁2,但是线程1在拿到锁1之后,还需要拿到锁2,来完成run方法,而线程2是需要拿到锁1,来完成run方法。但是线程1得等线程2执行完成之后释放锁2了,它才能拿到锁2,进而完成后面的run方法,线程2刚好相反,得等线程1执行完成之后释放锁1了,它才能拿到锁1。因此这就会造成互相等待,从而形成死锁的问题。
代码演示:
public class Test {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker1) { // 对locker1进行加锁
try {
// 避免t1在t2拿到locker2之前,拿到了locker2
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
for (int i = 0; i < 100000; i++) {
synchronized (locker2) { // 对locker进行加锁
count++;
}
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) { // 对locker2进行加锁
for (int i = 0; i < 100000; i++) {
synchronized (locker1) { // 对locker1进行加锁
count++;
}
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
上述代码由于线程之间相互请求、保持,因此形成了死锁。
原因、解决方法
死锁形成的原因有四点:
1、锁本身是互斥的,不能有多个线程同时访问。
2、锁是不可抢占的,不能有别的线程强行加锁给抢走了。
3、请求与保持。现象中描述的就是这种情况,线程既要其他的锁,又不肯放下这个锁。
4、循环等待。多个线程之间一直在等,不肯停下来。
对于1、2 是锁本身具有的特性,因此我们只能破坏3、4。
3 的解决方法是,尽量避免嵌套加锁的形式, 因为嵌套加锁会导致线程之间出现请求和保持的状况,虽然避免嵌套确实可以解决,但是有时候可能就需要嵌套呢。因此,我们更好的办法是针对 4,如果我们约定好线程之间的加锁顺序,那么当另一个线程去使用锁时,前面一个线程已经使用完了。
注意:频繁的加锁操作也是不可取的。因为加锁会使线程阻塞等待,那么就会造成效率低下。
内存可见性
前面我们知道了,造成线程安全问题的五大罪魁祸首:随机调度、多个线程同时修改同一个块内存空间、修改操作不是原子性、内存可见性、指令重排序。
接下来,我们学习内存可见性。CPU中有寄存器,用来存放当前线程中程序段需要的数据,以及计算的中间结果。当CPU频繁的从同一块内存空间中取出的值不变时,这时CPU就会直接绕过内存,去寄存器中取值。因为寄存器的存取速度是远远大于CPU的。编译器为了提高程序的运行效率,就会直接从寄存器中取值,因此后续内存中的值发生变化时,CPU不会感知到,其还是在寄存器中取值,因此这就导致程序除了BUG。因此这个内存可见性是编译器优化导致的。
代码演示:
public class Test {
private static int n = 0;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (n == 0) {
}
System.out.println("t线程结束");
});
t.start();
System.out.println("请输入n的值:");
Scanner scanner = new Scanner(System.in);
n = scanner.nextInt();
System.out.println("main线程结束");
}
}
即使上面我们输入了非零值,但是 t 线程依然还在跑。
注意:
1、虽然我们认为感知是一瞬间就输入了,但是对于计算机来说,那就是桑海沧田了。因此CPU就是直接从寄存器中取值了。
2、上面的代码是一个线程(main)在修改,一个线程(t)在读取。
针对上述代码,我们可以直接暴力休眠即可,让线程休眠一段时间等待用户的输入,这样就不会出现CPU直接从寄存器中取值了。但是更好的做法是使用volatile关键字。
volatile关键字
接下来,就使用 volatile 关键字修饰变量,这是表明这个变量是易变的,这样编译器就不会擅作主张了。
代码演示:
public class Test {
private static volatile int n = 0;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (n == 0) {
}
System.out.println("t线程结束");
});
t.start();
System.out.println("请输入n的值:");
Scanner scanner = new Scanner(System.in);
n = scanner.nextInt();
System.out.println("main线程结束");
}
}
好啦!本期 初始JavaEE篇——多线程(3):可重入锁、死锁、内存可见性、volatile关键字 的学习之旅就到此结束啦!我们下一期再一起学习吧!