1. 加锁的目的
对于count++这样的一个java语句,其底层是由三个基本操作组成的,我们在多线程中运行一个java语句,但是该语句的三个操作会被其他线程冲散,导致整个Java语句不能及时的一次性完成,这样就会导致我们的预期结果产生误差;
我们加锁就是使用synchronized关键字来将一个java语句的多个底层操作包装成一个原子性的整体(该行为叫加锁),不会在多线程抢占式执行的时候冲散;
synchronized关键字的两大特性:
1、互斥性
因为加锁具有互斥的特性,给一段代码加锁,当运行这段代码时,该段代码在系统上的指令就会就会被打包在一起,等这些指令被完全执行结束,其他的指令操作才能进行。如此也达到了加锁的目的:把几个操作打包成一个原子(整体)的操作。
2、可重入性
综上所述:加锁导致锁竞争,通过锁竞争让第二个线程的指令无法插入到第一个正在执行的线程指令中,而不是禁止第一个线程被调度出cpu;
2. 加锁和解锁
1、未加锁前
我们想让一个变量自增10_0000次,用两个线程来实现这一操作,分工各一半,没有加锁的操作,是有线程问题的,因为两个线程修改同一个变量的原因。代码如下:
public class ThreadDemo4 { private static int count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 1; i < 50000; i++) { count++; } }); Thread t2 = new Thread(() -> { for (int i = 50000; i <= 100000; i++) { count++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("count: " + count); } }
按照我们的逻辑,从1自增到10_0000,肯定是自增了10_0000次,但是结果如下图所示:
2、给代码加锁
给count++加上锁操作后的代码:
package thread; import java.util.Currency; public class ThreadDemo19 { private static int count = 0; public static void main(String[] args) throws InterruptedException { //随便创建一个对象就行 Object locker = new Object(); //创建两个线程,每个线程都针对上述的count变量循环自增5w次 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(); //打印count的结果 System.out.println("count:" + count); } }
执行结果如下,如此是我们预期的效果:
前1
3、加锁的最核心规则:
对于一个线程,针对一个锁对象进行加锁后,但也有其他线程,也尝试对这个对象进行加锁,如此就会产生阻塞,我们就把这种现象称为锁竞争 / 锁冲突。(假如我们为了保证代码的原子性,设计出锁对象对象,多线程运行的时候,每个线程在运行时都要求与锁对象在一起进行加锁后执行,但是考虑到只有一个锁对象,所以其他没有锁的线程只能阻塞等待,当加锁后的线程解锁后,其他线程在抢占这个锁对象来执行代码),图解如下:
4、加锁和解锁的执行过程,针对上述代码,简单的画图来讲解一下关于底层指令的执行逻辑:
3. 加锁之后的线程安全问题
3.1 两个线程,针对不同对象加锁
加锁用的不是同一个对象,则依旧存在线程安全问题,如下代码所示:
package thread; public class ThreadDemo21 { 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); } }
结果如下:
通过结果可知结果所示与我们的预期结果二者存在不同,故此可以判断依旧存在线程安全问题;
3.2 一个线程加锁,一个线程不加锁
代码如下:
package thread; public class ThreadDemo21 { 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.3 关于加锁操作的一些错误理解
3.3.1 多个线程调用同一个类的方法,对其方法中的变量加锁
修改最初的代码,把count放到Test t 对象中,在这里面count++,并且对其加锁,加锁对象是 this,其他线程再来调用Test中的方法。
package thread; class Test { public int count = 0; public void add() { synchronized(this) { count++; } } } public class ThreadDemo21 { 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); } }
结果如下:
如结果所示,这种情况是线程安全的。关于this的指向分析如下图:
分析:这里的Test类中add方法里对其加锁引用的对象是this,也就是当前Test类的实例对象,所以两个线程调用者方法的时候会产生锁竞争,结果也就可以达到我们的预期效果了。(将上述的count+=操作单独的创建了一个类来分离出来count变量,同时添加add方法来完善count自加操作,即该部分代码中的加锁对象是test类的实例对象(足以完成count++操作)),故此不存在线程安全问题;
3.3.2 Test类里的add方法里面,加锁的对象换成Test.class
相对于前一部分代码, 只有一点小改动。
代码如下:
package thread; class Test { public int count = 0; public void add() { synchronized(Test.class) { count++; } } } public class ThreadDemo21 { 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 拿到的都是同一个对象,就会有锁竞争,还是能保障线程安全的。
番外:关于反射的知识如下图所示:
在java进程中,由反射的知识可知,一个类只有一个类对象;
3.3.3 还可以把synchronized加到方法上(静态和普通方法都行)
上图所示表示锁的对象是当前方法所在的类;
3.4 一个线程被一个锁对象加锁两次(可重入性的例子 )
代码如下:
package thread; public class ThreadDemo21 { public static void main(String[] args) { Object locker = new Object(); Thread t = new Thread(() -> { synchronized (locker) { synchronized (locker) { // ... 随便写点啥都行 System.out.println("你好,不要温柔的走进良夜,微晚待续"); } // 其他逻辑~~ } }); t.start(); } }
结果如下:
分析:我们得到如此结果就是因为synchronized有可重入性的特性,可以对一个线程用同一个锁对象加锁多次。
通过如下图解,我们来了解一下可重入的底层逻辑:
synchronized用计数器的方式,就能避免两个锁之间的逻辑从而失去锁的保护,上述所说的就是锁的可重入的特性。
对于可重入锁来说,内部会持有两个信息:
1、当前这个锁被哪个线程持有的。
2、加锁次数的计数器。
3、这里的可重入性只针对java的synchronized关键字才有
4. 死锁
加锁能解决线程安全问题,但是如果加锁方式不当,就可能会产生死锁。
4.1 死锁的三种经典场景
4.1.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(); } }
以上这种情况也就像现实生活中,我们的钥匙锁房间里了,我们要想进入房间,就要拿到房间里的钥匙,但是房间是被锁着的,我们拿不到。
代码分析:遇到第一个synchronized,进行加锁,代码里的内容遇到第二个synchronized,锁对象是一样的,就要阻塞,等待第一个synchronized里的代码执行完才能执行第二个synchronized里的代码,因为第二个被阻塞等待了,所以会一直这样的阻塞的等待下去。
4.1.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为例子。当线程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 ThreadDemo21 { 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(); } }
结果如下:
4.1.3 N个线程,M把锁
哲学家就餐问题:
假设有五个哲学家,五把锁,一个哲学家就是一个线程,一个筷子就是一把锁,如图所示:
规则:一个哲学家要吃苗条的话要有一双筷子才能吃,而且只能拿哲学家左右两边的筷子,哲学家在吃面条的时候,不能被别的哲学家抢筷子
这时,如果每个哲学家,都同时拿起左边的筷子,这时,就没有多余的筷子给哲学家使用吃面条了,谁都不能吃到,谁都在等待,如图所示:
这就像一个线程,自己已经持有了一把锁,但还在尝试获取一把锁,但是每个不同锁都被不同线程加锁了,这时就等待别的线程释放锁,但是大家也都在阻塞等待别的锁释放,就会产生死锁问题。
此时想要解决这个问题,只要破坏产生死锁的4个必要条件的其中一个,就能解决死锁的问题,其中破坏循环等待是最简单的,具体方案如下:
我们规定,每个哲学家拿筷子编号比自己编号小的筷子,从编号为2的哲学家开始,如图:
到最后,1号哲学家就不能拿筷子了,5号能吃到面条,等5号吃完,4号就能吃,依次类推,每个哲学家就都能吃到面条了。
4.2 产生死锁的四个必要条件
1、互斥使用(获取锁的过程是互斥的,一个线程拿到了这把锁,其他线程想要拿到这把锁,就要阻塞,等待这个线程释放这把锁后,才能拿这把锁)
2、不可抢占(一个线程拿到一把锁,其他线程不能强行把这把锁抢走)
3、请求保持(一个线程拿到A锁,在持有A锁的前提下,同时尝试拿到B锁)
4、循环等待 / 环路等待。
破坏上述条件的难易程度:
1、互斥使用:锁的基本特性,不好破坏。
2、不可抢占:锁的基本特性,不好破坏。
3、请求保持:代码结构的原因,看实际需求,有时候能破坏,有时候不能破坏。
4、循环等待:代码结构的原因,最好破坏,指定一定的规则即可避免循环等待。
ps:本次的内容就到这里了,如果喜欢的话就请一键三连哦!!!