)
上一篇我们已经知道了synchronized加锁来解决线程安全问题修改操作不是原子的synchronized修饰普通方法相当于给this进行加锁synchronized修饰静态方法相当于给类对象加锁。synchronized-监视器锁 monitor lockJVM中采用的一个术语使用锁的过程中抛出一些异常可能会看到监视器锁这样的报错信息。1 synchronized的特性1互斥多个线程针对同一个对象加锁才会产生互斥锁冲突/锁竞争这个上一篇已经说过了2可重入要想理解可重入我们先来了解一下死锁的一种情况直接的代码示例在这段代码中第二次synchronized就得阻塞等待等到第一次加锁被释放第二次加锁的阻塞才会继续执行。看起来是两次一样的加锁没有必要但是在实际开发中很容易写出这样的代码比如这样的代码我们稍不注意就会写出来。这样的代码第一次加锁是可以成功的因为锁没有被使用第二次进行加锁锁是被占用的状态就得阻塞等待。要想解除阻塞需要往下执行才可以要想往下执行就需要等到第一次的锁被释放……这样的问题就成为“死锁”。值得注意的是这只是死锁的一种情况一个线程一把锁连续加锁多次。其他的情况稍后再说死锁是一个非常严重的bug会使代码执行到这一块之后就会被卡住。为了解决上述的问题Java的synchronized就引入了可重入的概念。可重入当某个线程针对一个锁加锁成功之后后续该线程再次针对这个锁进行加锁。不会触发阻塞而是继续往下走。但是如果是其他线程尝试加锁就会正常阻塞。可重入锁的实现原理关键在于让锁对象内部保存当前是哪个线程持有的这把锁。后续有线程针对这把锁加锁的时候进行对比看锁持有的线程是否和当前加锁的线程是同一个可以连续加锁多次最外层是真正加锁最外层也是真正解锁。站在JVM的视角看到多个}需要执行那它如何知道哪个}是真正解锁的那个会先引入一个变量计数器。每次触发{计数器每次触发}计数器--当计数器-- 为0的时候就是真正需要解锁的时候。那如何实现一个可重入锁呢1在锁内部记录当前是哪个线程持有的锁后续每次加锁都进行判定2通过计数器记录当前加锁的次数从而确定何时真正解锁2 关于死锁上面已经知道了一种情况一个线程一把锁连续加锁多次第二种情况两个线程两把锁每个线程获取到一把锁之后尝试获取对方的锁代码示例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(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker2){ System.out.println(t1线程两个锁都获取到); } } }); Thread t2 new Thread(()-{ synchronized (locker2){ try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker1){ System.out.println(t2线程两个锁都获取到); } } }); t1.start(); t2.start(); t1.join(); t2.join(); }值得注意的是必须是拿到第一把锁再拿第二把锁。必须是嵌套的关系这样的代码就构成了死锁那如果不加sleep,是否还会出现一样的现象呢那就得看具体的调度的顺序了。咱们加上sleep是为了确保t1拿到locker1t2拿到locker2等待1秒t1尝试拿locker2t2尝试拿locker1。如果不加sleep很可能t1一口气把locker1和locker2都拿了这个时候t2还没开动呢自然就构不成死锁。第三种情况N个线程M把锁一个经典的模型哲学家就餐问题哲学家相当于线程。筷子相当于锁。大部分情况下这个模型可以很好的运转只有在一些极端情况下会造成死锁。极端情况同一时刻大家都想吃面条同时拿起了左手边的筷子如上图所示此时任何一个线程都无法拿起右手边的筷子任何一个哲学家都吃不成面条那如何避免代码中出现死锁呢要想避免得先知道死锁是如何构成的构成死锁的四个必要条件重点1锁是互斥的一个线程拿到锁之后另一个线程在尝试获取锁就得阻塞等待2锁是不可抢占不可剥夺的线程1拿到锁线程2也尝试获取这个锁线程2必须阻塞等待而不是线程2直接把锁抢过来1和 2是锁的基本特性Java的synchronized是遵守这两点的3请求和保持一个线程拿到锁1之后在不释放锁1的情况下获取锁2在上面的哲学家的极端情况中如果先放下左手的筷子再拿右手的筷子就不会够成死锁那可能就说了代码中加锁的时候不去嵌套不就可以了。但是这种做法不是通用的有些时候就得嵌套4循环等待多个线程多把锁之间的等待过程构成了“循环”。A等待BB也等待A或者A等待BB等待CC等待A解决办法约定好加锁的顺序就可以破除循环等待了比如约定每个线程加锁的时候永远是先获取序号小的锁后获取序号大的锁代码示例运行结果避免死锁上述1和 2的情况是不可避免的3可以把嵌套的锁改为并列的锁4对加锁的顺序作出约定3 内存可见性是造成线程安全问题的原因之一代码示例运行结果我们可以看到虽然输入了非0的值但是此时t1线程循环并没有结束。很明显这是个bug是线程安全问题。一个线程读取一个线程修改修改线程修改的值但并没有被线程读取到这就是内存可见性问题。要想知道内存可见性问题得先谈谈编译器优化我们知道程序员的水平是参差不齐的研究JDK的大佬们就希望通过让编译器 JVM对程序员写的代码自动的进行优化。本来程序员写的代码是xxxx编译器/JVM会在你原有逻辑不变的前提下对你的代码进行调整使你的程序效率更高。值得注意的是编译器虽然声称优化操作是能够保证逻辑不变。但是在多线程的程序中编译器的判断可能会出现失误可能导致优化后的逻辑和优化前的逻辑出现细节上的偏差对于cmp这样的指令条件跳转load是读内存操作cmp是纯cpu寄存器操作load的时间开销可能是cmp的几千倍。短时间之内这个循环就会循环很多次执行过程中JVM就能感知到load反复执行的结果好像是一样的。这时JVM心想我执行这么多次读flag的操作值始终都是0既然都是一样的结果何必要反复执行这么多次呢。这个flag的值取决于用户输入不知道用户过多久才能输入于是就把读内存的操作优化成读取寄存器的操作把内存的值读到寄存器了后续再load就不再重新读内存了而是直接从寄存器里取。于是等到很多秒之后用户真正输入新的值真正修改flag此时t1线程就感知不到了编译器优化使得t1线程的读操作不是真正读内存如果我们微调上述代码就会得到不一样的结果代码示例运行结果本来这个while循环转的飞起1秒钟转几千万次……但是加了sleep(1)之后循环次数大幅度下降了当引入sleep之后sleep消耗的时间相比于上面的load flag的操作就不知道高了多少了。但是针对内存可见性问题不能指望通过sleep来解决因为使用sleep会大大影响到程序的效率。那该如何解决呢在语法中引入了volatile这个关键字通过这个关键字来修饰某个变量此时编译器通过对这个变量的读取操作就不会优化成读寄存器。代码示例运行结果OK啦到此结束