一、加锁的目的
synchronized的两大特性:
(1)互斥性
(2)可重入性(后面讲)
因为加锁具有互斥的特性,给一段代码加锁,当运行这段代码时,这里的代码在系统上的指令就会就会被打包在一起,等这些指令,执行完了,其他的指令操作才能进行。而这,也是加锁的目的:把几个操作打包成一个原子的操作。
二、加锁和解锁
我们想让一个变量自增10_0000次,用两个线程来实现这一操作,分工各一半,
没有加锁的操作,是有线程问题的,因为两个线程修改同一个变量的原因。代码如下:
public class ThreadDemo1 { private static int count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }
执行效果:
count并不是我们预期的10_0000。
当我们给count++加上锁操作后的代码:
public class ThreadDemo1 { private static int count = 0; public static void main(String[] args) throws InterruptedException { Object locker = new Object(); Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { synchronized(locker) { count++; } } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { synchronized(locker){ count++; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }
执行效果:
是我们预期的效果。
加锁的最核心规则:对于一个线程,针对一个对象进行加锁,但也有其他线程,也尝试对这个对象进行加锁,就会产生阻塞,这时,我们就把这已现象称为锁竞争 / 锁冲突。
例如下图,都是针对locker对象进行加锁,这时就会产生锁竞争
加锁和解锁的执行过程,针对上述代码,简单的画图展示一下:
也就是把t1先上锁,把t1的这几个操作打包成一个原子,执行完它们才能执行t2。
注意:给加锁的对象是任意的引用类型都可以的,我们也可以随便起个对象,但是要记住加锁的核心,两个线程之间加锁的对象,是否是同一个对象,是同一个对象,就会产生锁竞争,反之则不会。
三、加锁后是否会出现线程安全问题
1、两个线程,针对不同对象加锁
如下下代码,加锁用的不是同一个对象,则还是会存在线程安全问题
public class ThreadDemo1 { 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(() -> { for (int i = 0; i < 50000; i++) { synchronized(locker1) { count++; } } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { synchronized(locker2){ count++; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }
执行结果:
我们可以看到,结果和我们预期的10_0000不同,所以,还是存在线程安全问题。
2、一个线程加锁,一个线程不加锁
如下代码,和上面代码差不多,做一些小小的改动
public class ThreadDemo1 { 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(() -> { for (int i = 0; i < 50000; i++) { synchronized(locker1) { count++; } } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }
执行结果:
可以看到,和我们预期的结果不同,所以还是存在线程安全问题。
3、针对加锁操作的一些混淆理解
(1)多个线程调用同一个类的方法,对其方法里面的变量加锁
还是之前的代码模板,不过做了一些改动,把count放到Test t 对象中,在这里面count++,并且对其加锁,加锁对象是 this,其他线程再来调用Test中的方法。
class Test { public int count = 0; public void add() { synchronized(this) { count++; } } } public class ThreadDemo2 { 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.start(); t2.start(); t1.join(); t2.join(); System.out.println(t.count); } }
执行效果:
和我们的预期效果一样,这说明,这种情况是线程安全的。
解释:要解决这种情况的线程安全问题,核心还是上面所说的,看加锁是不是同一个对象,这里的Test类,add方法里对其加锁引用的对象是this,也就是当前Test类的实例对象,所有两个线程调用者方法的时候会产生锁竞争,结果也就可以达到我们的预期效果了。
(2)Test类里的add方法里面,加锁的对象换成Test.class
模板和之前差不多,只有一点小改动。代码如下:
class Test { public int count = 0; public void add() { synchronized(Test.class) { count++; } } } public class ThreadDemo2 { 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.start(); t2.start(); t1.join(); t2.join(); System.out.println(t.count); } }
执行结果:
结果和我们预期的原因,是线程安全的情况。
解释:这里的 Test.class 是类对象(反射那一块的内容),而 t1 和 t2 拿到的都是同一个对象,就会有锁竞争,还是能保障线程安全的。
(3)还可以把synchronized加到方法上(静态和普通方法都行)
如图:
表示锁的对象是当前方法所在的类,不过比较少用。
4、同一个线程用同一个锁对象加锁两次(可重入性的例子)
代码:
public class ThreadDemo1 { public static void main(String[] args) { Object locker = new Object(); Thread t1 = new Thread(() -> { synchronized(locker) { synchronized (locker) { System.out.println("hello"); } } }); t1.start(); } }
看到这段代码,同一个线程用同一个对象加锁两次,我们的第一反应是不是肯定会一直阻塞等待,打印不出来 hello ?答案是否定的,能打印出 hello,我们来看看执行结果:
这是为什么呢?就是因为synchronized有可重入性的特性,可以对一个线程用同一个锁对象加锁多次。
接下来,我们来解析一下这段代码,了解一下可重入性的内部操作。
(1)当我们第一次遇到synchronized的 “{ ”时,是真正的加锁,在synchronized内部有一个计数器会自增一次(++)。此时计数器里的值:1
(2)第二次遇到synchronized的 “ { ”,会判断要加锁的线程是否和正在持有锁线程是同一个线程,如果不是同一线程,则会阻塞等待正在持有锁线程,如果是同一线程,则synchronized内部的计数器会自增(++)的操作,就没别的操作了。此时计数器里的值:2
(3)第一次遇到 “ } ”,synchronized里的计数器自减一次(- -),此时计数器里的值:1 。因为计数器不为0,所以不会真的解锁。
(4)第二次遇到 “ } ”,synchronized里的计数器自减一次(++),此时计数器的值:0。因为计数器归零了,会进行真正的解锁。
用计数器的方式,就能避免两个所之间的逻辑,失去锁的保护,也就是锁的可重入的特性。
对于可重入锁来说,内部会持有两个信息:
1、当前这个锁被哪个线程持有的。
2、加锁次数的计数器。
注意:这里的可重入性只针对java的synchronized关键字才有,底层的代码是用C++写的,而C++没有可重入性的这特性。
四、死锁
死锁是多线程代码中的一类经典问题,同样是经典的面试题。加锁能解决线程安全问题,但是如果加锁方式不当,就可能会产生死锁。
1、产生死锁的四个必要条件
(1)互斥使用(获取锁的过程是互斥的,一个线程拿到了这把锁,其他线程想要拿到这把锁,就要阻塞,等待这个线程释放这把锁后,才能拿这把锁)
(2)不可抢占(一个线程拿到一把锁,其他线程不能强行把这把锁抢走)
(3)请求保持(一个线程拿到A锁,在持有A锁的前提下,同时尝试拿到B锁)
(4)循环等待 / 环路等待。
破坏上述条件的难易程度:
(1)互斥使用:锁的基本特性,不好破坏。
(2)不可抢占:锁的基本特性,不好破坏。
(3)请求保持:代码结构的原因,看实际需求,有时候能破坏,有时候不能破坏。
(4)循环等待:代码结构的原因,最好破坏,指定一定的规则即可避免循环等待。
2、死锁的三种经典场景
(1)一个线程,一把锁(针对锁是不可重入锁)
当锁是不可重入锁,在C++中,一个线程对这把锁加锁两次,就会产生死锁
代码:
public class ThreadDemo1 { public static void main(String[] args) { Object locker = new Object(); Thread t1 = new Thread(() -> { synchronized(locker) { synchronized (locker) { System.out.println("hello"); } } }); t1.start(); } }
以上是针对的是不可重入锁的情况,在C++中逻辑大概是这逻辑,代码肯定是不同风格的。这种情况就会产生死锁的情况。
这种情况也就像现实生活中,我们的钥匙锁房间里了,我们要想进入房间,就要拿到房间里的钥匙,但是房间是被锁着的,我们拿不到。
代码逻辑:遇到第一个synchronized,进行加锁,代码里的内容遇到第二个synchronized,锁对象是一样的,就要阻塞,等待第一个synchronized里的代码执行完才能执行第二个synchronized里的代码,因为第二个被阻塞等待了,所以会一直这样的阻塞的等待下去。
(2)两个线程,两把锁
线程1获取到了锁A,线程2获取到了锁B,这两条件下,线程A想获取锁B,线程B想获取锁A。这种情况,就会产生死锁。
代码:
public class ThreadDemo2 { public static void main(String[] args) { Object A = new Object(); Object B = new Object(); Thread t1 = new Thread(() -> { synchronized (A) { try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (B) { System.out.println("我获得了两把锁"); } } }); Thread t2 = new Thread(() -> { synchronized (B) { try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (A) { System.out.println("我获得了两把锁"); } } }); t1.start(); t2.start(); } }
执行结果:
这里会一直阻塞等待,我们可以用jconsole看当前线程的状态
这种情况,就像是钥匙锁车里了,车钥匙锁屋里了。
代码解析:线程1获取到了A锁,线程2也获得了B锁,因为线程的调度是随机的,所以接下来执行先执行线程1或者线程2,都行,我们以线程1为例子。当线程1想获取B锁时,因为线程2已经获取了锁B,就要等待线程2的锁B解锁后,线程1才能获取到B锁,但是线程2获取锁B后,它想获取锁A,因为锁A已经被线程1获取了,就要等线程1的A锁解锁后才能获取到锁A,这样就造成了阻塞等待,线程1等待B锁解锁,线程2等待锁A解锁,两个线程里的锁都无法解锁,就一直卡着不动。
解决方案:
破坏产生死锁四个必要条件之一:循环等待。
给加锁指定一定规则,例如:1线程获取A锁后,再获取B锁,2线程获取A锁后,再获取B锁。
代码如下:
public class ThreadDemo2 { public static void main(String[] args) { Object A = new Object(); Object B = new Object(); Thread t1 = new Thread(() -> { synchronized (A) { try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (B) { System.out.println("我获得了两把锁"); } } }); Thread t2 = new Thread(() -> { synchronized (A) { try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (B) { System.out.println("我获得了两把锁"); } } }); t1.start(); t2.start(); } }
执行结果:
(3)N个线程,M把锁
哲学家就餐问题
假设有五个哲学家,五把锁,一个哲学家就是一个线程,一个筷子就是一把锁,如图:
规则:一个哲学家要吃苗条的话要有一双筷子才能吃,而且只能拿哲学家左右两边的筷子,哲学家在吃面条的时候,不能被别的哲学家抢筷子。
这时,如果每个哲学家,都同时拿起左边的筷子,这时,就没有多余的筷子给哲学家使用吃面条了,谁都不能吃到,谁都在等待,如图:
这就像一个线程,自己已经持有了一把锁,但还在尝试获取一把锁,但是每个不同锁都被不同线程加锁了,这时就等待别的线程释放锁,但是大家也都在阻塞等待别的锁释放,就会产生死锁问题。
那这时,要怎么办呢?只要破坏产生死锁的4个必要条件的其中一个,就能解决死锁的问题,其中破坏循环等待是最简单的。
这时,我们规定,每个哲学家拿筷子编号比自己编号小的筷子,从编号为2的哲学家开始,如图:
到最后,1号哲学家就不能拿筷子了,5号能吃到面条,等5号吃完,4号就能吃,依次类推,每个哲学家就都能吃到面条了。线程也差不多,给加锁指定一定的规则,就能有效避免循环等待问题了。
注意:解决死锁问题不止破坏四个必要条件这么点,解决死锁问题也有很多方案,例如:
(1)引入额外的筷子
(2)去掉一个哲学家
(3)引入计数器,限制同一时间多少个人吃面
(4)引入加锁顺序的规则(普适性高,方案容易落实)
(5)学校操作系统课中的 “银行家算法”(这个方案确实能解决死锁问题,但是实际开发中不建议这么搞,因为这算法太难了,可能你锁的逻辑没问题,算法有bug了,导致整个进程跑不了;理论可行,实践不推荐)
五、联系其他的相关知识点
1、StringBuffer 和 StringBuilder
StringBuffer是线程安全的,就是在一些关键方法上,加上了synchronized;StringBuilder不是线程安全的,就是在一些关键方法上,没加synchronized。
其实通俗的说是不是线程安全,并不严谨,例如上面的代码例子,一个线程加锁,一个线程不加锁,或者两个线程给不同的对象加锁,任然是线程不安全的。具体还是要看代码怎么写。
2、C++加锁、解锁和java的区别
C++:它的加锁和解锁和java是不同的,在C++里,加锁:locker.lock() 解锁:locker.unlock(),他们的加锁和解锁是分开执行的,C++这种写法可能导致程序猿忘记调用unlock,或者unlock没执行到,这时就会产生很严重的bug,没解锁,其他加锁用和它一样对象的线程,就会一直等待。
java:它的是使用synchronized方法进行加锁,解锁的,这些操作已经打包好了,当synchronized代码块执行完后,就会自动释放锁,就不会有忘记或者没的解锁这种情况。