目录
一,synchronized的特性
1.1 互斥性
1.2 可重入性
二, 死锁
2.1 死锁产生的原因
三,volatile 关键字
3.1 能保证内存可见性
3.2 无原子性
当两个线程对同一个对象加锁时,后加锁的线程要 "阻塞等待" ,直到第一个线程的锁释放。
我们都知道当两个不同的线程正对同一个对象进行 "加锁" 时,会出现锁竞争,那么如果当一个线程对同一个对象连续 "加锁" 时,会怎么样呢?,比如下面的代码:
public class Demo2 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker){
synchronized (locker){
System.out.println("t1执行");
}
}
});
t1.start();
t1.join();
}
}
按照之前说的,当 t1 线程第一次 "加锁" 成功后,此时的 locker 就属于是 "被锁定" 状态,那么当 t1 线程进行第二次 "加锁" 时,原则上是要 "阻塞等待" ,等到锁(locker)被释放了之后,才能再次 "加锁" ,但是在上述的代码中,只要第二次(locker)不加锁,第一个锁(locker)就不会被释放,而第一个锁(locker)不释放,第二次(locker)又不能加锁,这样的话,在逻辑上,上述代码中的线程 t1 就会产生死锁从而卡死,很明显,这是一个BUG。
但是在日常开发中,这个BUG又很难避免,这时候有人会说,你上面的代码不是一眼就看出来了,这还不好避免?我在这里再举一个例子:
class Test1{
Object locker = new Object();
public void fun1(){
synchronized (locker){
fun2();
}
}
public void fun2(){
fun3();
}
public void fun3(){
fun4();
}
public void fun4(){
synchronized (locker){
System.out.println("4444");
}
}
}
//当出现上述代码时,当我们调用 fun1 方法,根本看不出来有什么问题!!!
所以为了解决上述的BUG,设计Java的那群人就把 synchronized 设计成 "可重入锁" ,就是说当一个线程对同一个对象连续加锁时,这个锁会自己记录是哪个线程给它 "加锁" ,这样后续再次加锁时,如果加锁线程就是当前持有锁的线程,就直接加锁成功。
这里我要提出一个问题:synchronized 虽然是可重入锁,避免了死锁,但是当一个线程对同一个对象加 N 层锁时,我们的锁什么时候释放?以及计算机如何判断锁释放的时机?第一个问题很简单,肯定是要第一次加锁的{}执行结束锁才能释放,第二个问题:锁对象不光会统计是谁拿到了锁,还会记录锁被加了几次,每一次加锁,计数器+1;每一次解锁,计数器-1。当出了最后一个{},计数器恰好为0,释放锁。
产生死锁的情况:
1. 如果 synchronized 没有可重入性,对同一个对象连续加锁
2. 两个线程,两把锁,synchronized嵌套使用(可能产生!!!)。例如:
public class Demo2 { public static void main(String[] args) throws InterruptedException { Object locker1 = new Object(); Object locker2 = new Object(); Thread t1 = new Thread(() -> { synchronized (locker1){ try { Thread.sleep(1);//为了让t1拿到locker1,t2拿到locker2 } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker2){ System.out.println("t1结束"); } } }); Thread t2 = new Thread(() -> { synchronized (locker2){ synchronized (locker1){ System.out.println("t2结束"); } } }); t1.start(); t2.start(); t1.join(); t2.join(); } }
3. M个线程,N把锁(相当于2的推论)
四个必要条件(只要下面4个条件中有一个不成立,那么就不会形成死锁):
- 互斥使用(锁的基本特性)即当两个线程对同一个对象加锁时,后加锁的线程要 "阻塞等待" ,直到第一个线程的锁释放。
- 不可抢占(锁的基本特性)与第一点相同
- 请求保持,即锁和锁之间可以嵌套使用
- 循环等待/环路等待,即等待的依赖关系形成了环,比如:车钥匙锁家里了,家钥匙锁车里了。与上述代码一个情况
那么我们如何解决死锁?
因为上述的4个条件中,前两个条件是锁自带的属性,无法干预,因此我们只能从后两个条件入手。对于条件3:只要我们避免编写锁嵌套的逻辑就行。(但是有的情况下,这是无法避免的)对于条件4:给锁编号,约定加锁的顺序,比如:约定先加编号大的锁,后加编号小的锁,所有的线程都要遵守。
什么是内存可见性?
在计算机运行代码时,要经常访问数据,这些数据往往存储在内存中(定义一个变量,这个变量就存储在内存中)。而cpu读取内存的这个操作,比cpu读取寄存器慢了几万倍,这时就会出现,cpu在解决大部分的情况时,速度很快,一旦读取内存,速度瞬间就慢下来了的情况。
为了解决上述问题,提高运行效率,此时编译器就会对代码做出优化,把一些原本读取内存的操作,优化成读取寄存器,这样就减少了读内存的次数,从而提高了整体的效率。举一个例子:
public class Demo3 {
static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while(flag){
}
System.out.println("循环结束!");
});
t1.start();
Thread.sleep(10);
flag = false;
t1.join();
}
}
很明显,即使我们将flag改成false,线程也没有停止循环,这就是 "内存可见性" 引起的,由于该循环没有任何其他操作,循环速度非常快,就会进行大量的 load(将flag读取到内存) ,cmp 操作。此时编译器发现,虽然进行了多次 load 操作,但是 flag 的值没有改变,而 load 操作又很浪费时间,所以编译器直接就在第一次循环的时候,读内存,将flag存到寄存器中,后续就不读内存了,直接从寄存器中取出 flag,这就是导致 "内存可见性" 问题。
而 volatile 关键字可以解决这一问题,只要被 volatile 修饰的变量,编译器就不可以对其进行优化,也就是说,该变量必须从内存中读取。例如:
public class Demo3 {
volatile static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while(flag){
}
System.out.println("循环结束!");
});
t1.start();
Thread.sleep(10);
flag = false;
t1.join();
}
}
还有一点需要注意的是,编译器优化的触发是不确定的,我们不知道它什么时候触发,什么时候不触发。所以最好使用 volatile 关键字!!!
volatile 关键字不能像 synchronized 关键字那样让 count++ 这类操作变成原子操作!!例如:
public class Test {
static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
比如说我们常常使用的 new 操作,它会大概分成三步 : 1. 向内存申请一块空间 2. 实例化变量 3. 返回该空间的引用,它可以是 1 -> 2 -> 3 ,也可以是 1 -> 3 -> 2,在有些情况中,我们必须要保持 1 -> 2 -> 3 的顺序,这时候我们就可以使用 volatile 关键字。