Java面试线程与锁,尸横遍野!
再谈多线程在我们的操作系统之上可以同时运行很多个进程并且每个进程之间相互隔离互不干扰。我们的CPU会通过时间片轮转算法为每一个进程分配时间片并在时间片使用结束后切换下一个进程继续执行通过这种方式来实现宏观上的多个程序同时运行。由于每个进程都有一个自己的内存空间进程之间的通信就变得非常麻烦比如要共享某些数据而且执行不同进程会产生上下文切换非常耗时那么有没有一种更好地方案呢后来线程横空出世一个进程可以有多个线程线程是程序执行中一个单一的顺序控制流程现在线程才是程序执行流的最小单元各个线程之间共享程序的内存空间也就是所在进程的内存空间上下文切换速度也高于进程。现在有这样一个问题public static void main(String[] args) { int[] arr new int[]{3, 1, 5, 2, 4}; //请将上面的数组按升序输出 }按照正常思维我们肯定是这样public static void main(String[] args) { int[] arr new int[]{3, 1, 5, 2, 4}; //直接排序吧 Arrays.sort(arr); for (int i : arr) { System.out.println(i); } }而我们学习了多线程之后可以换个思路来实现public static void main(String[] args) { int[] arr new int[]{3, 1, 5, 2, 4}; for (int i : arr) { new Thread(() - { try { Thread.sleep(i * 1000); //越小的数休眠时间越短优先被打印 System.out.println(i); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); } }我们接触过的很多框架都在使用多线程比如Tomcat服务器所有用户的请求都是通过不同的线程来进行处理的这样我们的网站才可以同时响应多个用户的请求要是没有多线程可想而知服务器的处理效率会有多低。在Java 5的时候新增了java.util.concurrentJUC包其中包括大量用于多线程编程的工具类目的是为了更好的支持高并发任务让开发者进行多线程编程时减少竞争条件和死锁的问题并发与并行我们经常听到并发编程那么这个并发代表的是什么意思呢而与之相似的并行又是什么意思它们之间有什么区别比如现在一共有三个工作需要我们去完成。顺序执行顺序执行其实很好理解就是我们依次去将这些任务完成了实际上就是我们同一时间只能处理一个任务所以需要前一个任务完成之后才能继续下一个任务依次完成所有任务。并发执行并发执行也是我们同一时间只能处理一个任务但是我们可以每个任务轮着做时间片轮转而我们Java中的线程正是这种机制当我们需要同时处理上百个上千个任务时很明显CPU的数量是不可能赶得上我们的线程数的所以说这时就要求我们的程序有良好的并发性能来应对同一时间大量的任务处理。学习Java并发编程能够让我们在以后的实际场景中知道该如何应对高并发的情况。并行执行并行执行就突破了同一时间只能处理一个任务的限制我们同一时间可以做多个任务比如我们要进行一些排序操作就可以用到并行计算只需要等待所有子任务完成最后将结果汇总即可。包括分布式计算模型MapReduce也是采用的并行计算思路。再谈锁机制谈到锁机制相信各位应该并不陌生了我们在JavaSE阶段通过使用synchronized关键字来实现锁这样就能够很好地解决线程之间争抢资源的情况。那么synchronized底层到底是如何实现的呢我们知道使用synchronized一定是和某个对象相关联的比如我们要对某一段代码加锁那么我们就需要提供一个对象来作为锁本身public static void main(String[] args) { synchronized (Main.class) { //这里使用的是Main类的Class对象作为锁 } }我们来看看它变成字节码之后会用到哪些指令其中最关键的就是monitorenter指令了可以看到之后也有monitorexit与之进行匹配注意这里有2个monitorenter和monitorexit分别对应加锁和释放锁在执行monitorenter之前需要尝试获取锁。每个对象都有一个monitor监视器与之对应而这里正是去获取对象监视器的所有权一旦monitor所有权被某个线程持有那么其他线程将无法获得管程模型的一种实现。在代码执行完成之后我们可以看到一共有两个monitorexit在等着我们那么为什么这里会有两个呢按理说monitorenter和monitorexit不应该一一对应吗这里为什么要释放锁两次呢首先我们来看第一个这里在释放锁之后会马上进入到一个goto指令跳转到15行而我们的15行对应的指令就是方法的返回指令其实正常情况下只会执行第一个monitorexit释放锁在释放锁之后就接着同步代码块后面的内容继续向下执行了。而第二个其实是用来处理异常的可以看到它的位置是在12行如果程序运行发生异常那么就会执行第二个monitorexit并且会继续向下通过athrow指令抛出异常而不是直接跳转到15行正常运行下去。实际上synchronized使用的锁就是存储在Java对象头中的我们知道对象是存放在堆内存中的而每个对象内部都有一部分空间用于存储对象头信息。而对象头信息中则包含了Mark Word用于存放hashCode和对象的锁信息在不同状态下它存储的数据结构有一些不同。重量级锁在JDK6之前synchronized一直被称为重量级锁monitor依赖于底层操作系统的Lock实现。Java的线程是映射到操作系统的原生线程上切换成本较高。而在JDK6之后锁的实现得到了改进。我们先从最原始的重量级锁开始我们说了每个对象都有一个monitor与之关联在Java虚拟机HotSpot中monitor是由ObjectMonitor实现的ObjectMonitor() { _header NULL; _count 0; //记录个数 _waiters 0, _recursions 0; _object NULL; _owner NULL; _WaitSet NULL; //处于wait状态的线程会被加入到_WaitSet _WaitSetLock 0 ; _Responsible NULL ; _succ NULL ; _cxq NULL ; FreeNext NULL ; _EntryList NULL ; //处于等待锁block状态的线程会被加入到该列表 _SpinFreq 0 ; _SpinClock 0 ; OwnerIsThread 0 ; }每个等待锁的线程都会被封装成ObjectWaiter对象进入到如下机制设计思路ObjectWaiter首先会进入 Entry Set等着当线程获取到对象的monitor后进入 The Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1若线程调用wait()方法将释放当前持有的monitorowner变量恢复为nullcount自减1同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor并复位变量的值以便其他线程进入获取对象的monitor。虽然这样的设计思路非常合理但是在大多数应用上每一个线程占用同步代码块的时间并不是很长我们完全没有必要将竞争中的线程挂起然后又唤醒并且现代CPU基本都是多核心运行的我们可以采用一种新的思路来实现锁。新思路在JDK1.4.2时引入了自旋锁JDK6之后默认开启它不会将处于等待状态的线程挂起而是通过无限循环的方式不断检测是否能够获取锁。由于单个线程占用锁的时间非常短所以说循环次数不会太多可能很快就能够拿到锁并运行这就是自旋锁。当然仅仅是在等待时间非常短的情况下自旋锁的表现会很好但是如果等待时间太长由于循环是需要处理器继续运算的所以这样只会浪费处理器资源因此自旋锁的等待时间是有限制的默认情况下为10次如果失败那么会进而采用重量级锁机制。在JDK6之后自旋锁得到了一次优化自旋的次数限制不再是固定的而是自适应变化的。比如在同一个锁对象上自旋等待刚刚成功获得过锁并且持有锁的线程正在运行那么这次自旋也是有可能成功的所以会允许自旋更多次。当然如果某个锁经常都自旋失败那么有可能会不再采用自旋策略而是直接使用重量级锁。轻量级锁从JDK 1.6开始为了减少获得锁和释放锁带来的性能消耗就引入了轻量级锁。轻量级锁的目标是在无竞争情况下减少重量级锁产生的性能消耗并不是为了代替重量级锁实际上就是赌一手同一时间只有一个线程在占用资源包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。它不像是重量级锁那样需要向操作系统申请互斥量。运作机制在即将开始执行同步代码块中的内容时会首先检查对象的Mark Word查看锁对象是否被其他线程占用如果没有任何线程占用那么会在当前线程中所处的栈帧中建立一个名为锁记录Lock Record的空间用于复制并存储对象目前的Mark Word信息官方称为Displaced Mark Word。接着虚拟机将使用CAS操作将对象的Mark Word更新为轻量级锁状态数据结构变为指向Lock Record的指针指向的是当前的栈帧CASCompare And Swap是一种无锁算法它并不会为对象加锁而是在执行的时候看看当前数据的值是不是我们预期的那样如果是那就正常进行替换如果不是那么就替换失败。比如有两个线程都需要修改变量i的值默认为10现在一个线程要将其修改为20另一个要修改为30如果他们都使用CAS算法那么并不会加锁访问i而是直接尝试修改i的值但是在修改时需要确认i是不是10如果是表示其他线程还没对其进行修改如果不是那么说明其他线程已经将其修改此时不能完成修改任务修改失败。在CPU中CAS操作使用的是cmpxchg指令能够从最底层硬件层面得到效率的提升。如果CAS操作失败了的话那么说明可能这时有线程已经进入这个同步代码块了这时虚拟机会再次检查对象的Mark Word是否指向当前线程的栈帧如果是说明不是其他线程而是当前线程已经有了这个对象的锁直接放心大胆进同步代码块即可。如果不是那确实是被其他线程占用了。这时轻量级锁一开始的想法就是错的这时有对象在竞争资源已经赌输了所以说只能将锁膨胀为重量级锁按照重量级锁的操作执行注意锁的膨胀是不可逆的如图所示所以轻量级锁 - 失败 - 自适应自旋锁 - 失败 - 重量级锁解锁过程同样采用CAS算法如果对象的MarkWord仍然指向线程的锁记录那么就用CAS操作把对象的MarkWord和复制到栈帧中的Displaced Mark Word进行交换。如果替换失败说明其他线程尝试过获取该锁在释放锁的同时需要唤醒被挂起的线程。偏向锁偏向锁相比轻量级锁更纯粹干脆就把整个同步都消除掉不需要再进行CAS操作了。它的出现主要是得益于人们发现某些情况下某个锁频繁地被同一个线程获取这种情况下我们可以对轻量级锁进一步优化。偏向锁实际上就是专门为单个线程而生的当某个线程第一次获得锁时如果接下来都没有其他线程获取此锁那么持有锁的线程将不再需要进行同步操作。可以从之前的MarkWord结构中看到偏向锁也会通过CAS操作记录线程的ID如果一直都是同一个线程获取此锁那么完全没有必要在进行额外的CAS操作。当然如果有其他线程来抢了那么偏向锁会根据当前状态决定是否要恢复到未锁定或是膨胀为轻量级锁。如果我们需要使用偏向锁可以添加-XX:UseBiased参数来开启。所以最终的锁等级为未锁定 偏向锁 轻量级锁 重量级锁值得注意的是如果对象通过调用hashCode()方法计算过对象的一致性哈希值那么它是不支持偏向锁的会直接进入到轻量级锁状态因为Hash是需要被保存的而偏向锁的Mark Word数据结构无法保存Hash值如果对象已经是偏向锁状态再去调用hashCode()方法那么会直接将锁升级为重量级锁并将哈希值存放在monitor有预留位置保存中。锁消除和锁粗化锁消除和锁粗化都是在运行时的一些优化方案。比如我们某段代码虽然加了锁但是在运行时根本不可能出现各个线程之间资源争夺的情况这种情况下完全不需要任何加锁机制所以锁会被消除。锁粗化则是我们代码中频繁地出现互斥同步操作比如在一个循环内部加锁这样明显是非常消耗性能的所以虚拟机一旦检测到这种操作会将整个同步范围进行扩展。JMM内存模型注意这里提到的内存模型和我们在JVM中介绍的内存模型不在同一个层次JVM中的内存模型是虚拟机规范对整个内存区域的规划而Java内存模型是在JVM内存模型之上的抽象模型具体实现依然是基于JVM内存模型实现的以前的文章有介绍。https://www.cnblogs.com/zwtblog/tag/侧边栏支持搜索。Java内存模型我们在计算机组成原理中学习过在我们的CPU中一般都会有高速缓存而它的出现是为了解决内存的速度跟不上处理器的处理速度的问题。所以CPU内部会添加一级或多级高速缓存来提高处理器的数据获取效率但是这样也会导致一个很明显的问题因为现在基本都是多核心处理器每个处理器都有一个自己的高速缓存那么又该怎么去保证每个处理器的高速缓存内容一致呢为了解决缓存一致性的问题需要各个处理器访问缓存时都遵循一些协议在读写时要根据协议来进行操作。这类协议有MSI、MESIIllinois Protocol、MOSI、Synapse、Firefly及Dragon Protocol等。而Java也采用了类似的模型来实现支持多线程的内存模型JMMJava Memory Model内存模型规定如下所有的变量全部存储在主内存注意这里包括下面提到的变量指的都是会出现竞争的变量包括成员变量、静态变量等而局部变量这种属于线程私有不包括在内每条线程有着自己的工作内存可以类比CPU的高速缓存线程对变量的所有操作必须在工作内存中进行不能直接操作主内存中的数据。不同线程之间的工作内存相互隔离如果需要在线程之间传递内容只能通过主内存完成无法直接访问对方的工作内存。也就是说每一条线程如果要操作主内存中的数据那么得先拷贝到自己的工作内存中并对工作内存中数据的副本进行操作操作完成之后也需要从工作副本中将结果拷贝回主内存中具体的操作就是Save保存和Load加载操作。那么各位肯定会好奇这个内存模型结合之前JVM所讲的内容具体是怎么实现的呢主内存对应堆中存放对象的实例的部分。工作内存对应线程的虚拟机栈的部分区域虚拟机可能会对这部分内存进行优化将其放在CPU的寄存器或是高速缓存中。比如在访问数组时由于数组是一段连续的内存空间所以可以将一部分连续空间放入到CPU高速缓存中那么之后如果我们顺序读取这个数组那么大概率会直接缓存命中。前面我们提到在CPU中可能会遇到缓存不一致的问题而Java中也会遇到比如下面这种情况public class Main { private static int i 0; public static void main(String[] args) throws InterruptedException { new Thread(() - { for (int j 0; j 100000; j) i; System.out.println(线程1结束); }).start(); new Thread(() - { for (int j 0; j 100000; j) i; System.out.println(线程2结束); }).start(); //等上面两个线程结束 Thread.sleep(1000); System.out.println(i); } }可以看到这里是两个线程同时对变量i各自进行100000次自增操作但是实际得到的结果并不是我们所期望的那样。那么为什么会这样呢在之前学习了JVM之后相信各位应该已经知道自增操作实际上并不是由一条指令完成的注意一定不要理解为一行代码就是一个指令完成的包括变量i的获取、修改、保存都是被拆分为一个一个的操作完成的那么这个时候就有可能出现在修改完保存之前另一条线程也保存了但是当前线程是毫不知情的。所以说在JavaSE阶段讲解这个问题的时候是通过synchronized关键字添加同步代码块解决的另外的解决方案原子类。重排序在编译或执行时为了优化程序的执行效率编译器或处理器常常会对指令进行重排序有以下情况编译器重排序Java编译器通过对Java代码语义的理解根据优化规则对代码指令进行重排序。机器指令级别的重排序现代处理器很高级能够自主判断和变更机器指令的执行顺序。指令重排序能够在不改变结果单线程的情况下优化程序的运行效率比如public static void main(String[] args) { int a 10; int b 20; System.out.println(a b); }我们其实可以交换第一行和第二行public static void main(String[] args) { int b 10; int a 20; System.out.println(a b); }即使发生交换但是我们程序最后的运行结果是不会变的当然这里只通过代码的形式演示实际上JVM在执行字节码指令时也会进行优化可能两个指令并不会按照原有的顺序进行。虽然单线程下指令重排确实可以起到一定程度的优化作用但是在多线程下似乎会导致一些问题public class Main { private static int a 0; private static int b 0; public static void main(String[] args) { new Thread(() - { if(b 1) { if(a 0) { System.out.println(A); }else { System.out.println(B); } } }).start(); new Thread(() - { a 1; b 1; }).start(); } }上面这段代码在正常情况下按照我们的正常思维是不可能输出A的因为只要b等于1那么a肯定也是1才对因为a是在b之前完成的赋值。但是如果进行了重排序那么就有可能a和b的赋值发生交换b先被赋值为1而恰巧这个时候线程1开始判定b是不是1了这时a还没来得及被赋值为1可能线程1就已经走到打印那里去了所以是有可能输出A的。volatile关键字关键字volatile开始之前我们先介绍三个词语原子性其实之前讲过很多次了就是要做什么事情要么做完要么就不做不存在做一半的情况。可见性指当多个线程访问同一个变量时一个线程修改了这个变量的值其他线程能够立即看得到修改的值。有序性即程序执行的顺序按照代码的先后顺序执行。我们之前说了如果多线程访问同一个变量那么这个变量会被线程拷贝到自己的工作内存中进行操作而不是直接对主内存中的变量本体进行操作。下面这个操作看起来是一个有限循环但是是无限的public class Main { private static int a 0; public static void main(String[] args) throws InterruptedException { new Thread(() - { while (a 0); System.out.println(线程结束); }).start(); Thread.sleep(1000); System.out.println(正在修改a的值...); a 1; //很明显按照我们的逻辑来说a的值被修改那么另一个线程将不再循环 } }实际上这就是我们之前说的虽然我们主线程中修改了a的值但是另一个线程并不知道a的值发生了改变所以循环中依然是使用旧值在进行判断因此普通变量是不具有可见性的。要解决这种问题我们第一个想到的肯定是加锁同一时间只能有一个线程使用这样总行了吧确实这样的话肯定是可以解决问题的public class Main { private static int a 0; public static void main(String[] args) throws InterruptedException { new Thread(() - { while (a 0) { synchronized (Main.class){} } System.out.println(线程结束); }).start(); Thread.sleep(1000); System.out.println(正在修改a的值...); synchronized (Main.class){ a 1; } } }但是除了硬加一把锁的方案我们也可以使用volatile关键字来解决。此关键字的第一个作用就是保证变量的可见性。当写一个volatile变量时JMM会把该线程本地内存中的变量强制刷新到主内存中去并且这个写会操作会导致其他线程中的volatile变量缓存无效。这样另一个线程修改了这个变时当前线程会立即得知并将工作内存中的变量更新为最新的版本。那么我们就来试试看public class Main { //添加volatile关键字 private static volatile int a 0; public static void main(String[] args) throws InterruptedException { new Thread(() - { while (a 0); System.out.println(线程结束); }).start(); Thread.sleep(1000); System.out.println(正在修改a的值...); a 1; } }结果还真的如我们所说的那样当a发生改变时循环立即结束。当然虽然说volatile能够保证可见性但是不能保证原子性要解决我们上面的i的问题可以使用加锁来完成public class Main { private static volatile int a 0; public static void main(String[] args) throws InterruptedException { Runnable r () - { for (int i 0; i 10000; i) a; System.out.println(任务完成); }; new Thread(r).start(); new Thread(r).start(); //等待线程执行完成 Thread.sleep(1000); System.out.println(a); } }volatile不是能在改变变量的时候其他线程可见吗那为什么还是不能保证原子性呢还是那句话自增操作是被瓜分为了多个步骤完成的虽然保证了可见性但是只要手速够快依然会出现两个线程同时写同一个值的问题比如线程1刚刚将a的值更新为100这时线程2可能也已经执行到更新a的值这条指令了已经刹不住车了所以依然会将a的值再更新为一次100那要是真的遇到这种情况那么我们不可能都去写个锁吧后面我们会介绍原子类来专门解决这种问题。最后一个功能就是volatile会禁止指令重排也就是说如果我们操作的是一个volatile变量它将不会出现重排序的情况.那么它是怎么解决的重排序问题呢若用volatile修饰共享变量在编译时会在指令序列中插入内存屏障来禁止特定类型的处理器重排序内存屏障Memory Barrier又称内存栅栏是一个CPU指令它的作用有两个保证特定操作的顺序保证某些变量的内存可见性volatile的内存可见性其实就是依靠这个实现的由于编译器和处理器都能执行指令重排的优化如果在指令间插入一条Memory Barrier则会告诉编译器和CPU不管什么指令都不能和这条Memory Barrier指令重排序。屏障类型指令示例说明LoadLoadLoad1;LoadLoad;Load2保证Load1的读取操作在Load2及后续读取操作之前执行StoreStoreStore1;StoreStore;Store2在Store2及其后的写操作执行前保证Store1的写操作已刷新到主内存LoadStoreLoad1;LoadStore;Store2在Store2及其后的写操作执行前保证Load1的读操作已读取结束StoreLoadStore1;StoreLoad;Load2保证load1的写操作已刷新到主内存之后load2及其后的读操作才能执行所以volatile能够保证之前的指令一定全部执行之后的指令一定都没有执行并且前面语句的结果对后面的语句可见。最后我们来总结一下volatile关键字的三个特性保证可见性不保证原子性防止指令重排在之后我们的设计模式系列视频中还会讲解单例模式下volatile的运用。happens-before原则经过我们前面的讲解相信各位已经了解了JMM内存模型以及重排序等机制带来的优点和缺点.综上JMM提出了happens-before先行发生原则定义一些禁止编译优化的场景来向各位程序员做一些保证只要我们是按照原则进行编程那么就能够保持并发编程的正确性。具体如下程序次序规则同一个线程中按照程序的顺序前面的操作happens-before后续的任何操作。同一个线程内代码的执行结果是有序的。其实就是可能会发生指令重排但是保证代码的执行结果一定是和按照顺序执行得到的一致程序前面对某一个变量的修改一定对后续操作可见的不可能会出现前面才把a修改为1接着读a居然是修改前的结果这也是程序运行最基本的要求。监视器锁规则对一个锁的解锁操作happens-before后续对这个锁的加锁操作。就是无论是在单线程环境还是多线程环境对于同一个锁来说一个线程对这个锁解锁之后另一个线程获取了这个锁都能看到前一个线程的操作结果。比如前一个线程将变量x的值修改为了12并解锁之后另一个线程拿到了这把锁对之前线程的操作是可见的可以得到x是前一个线程修改后的结果12所以synchronized是有happens-before规则的volatile变量规则对一个volatile变量的写操作happens-before后续对这个变量的读操作。就是如果一个线程先去写一个volatile变量紧接着另一个线程去读这个变量那么这个写操作的结果一定对读的这个变量的线程可见。线程启动规则主线程A启动线程B线程B中可以看到主线程启动B之前的操作。在主线程A执行过程中启动子线程B那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。线程加入规则如果线程A执行操作join()线程B并成功返回那么线程B中的任意操作happens-before线程Ajoin()操作成功返回。传递性规则如果A happens-before BB happens-before C那么A happens-before C。那么我们来从happens-before原则的角度来解释一下下面的程序结果public class Main { private static int a 0; private static int b 0; public static void main(String[] args) { a 10; b a 1; new Thread(() - { if(b 10) System.out.println(a); }).start(); } }首先我们定义以上出现的操作A将变量a的值修改为10B将变量b的值修改为a 1C主线程启动了一个新的线程并在新的线程中获取b进行判断如果为true那么就打印a首先我们来分析由于是同一个线程并且B是一个赋值操作且读取了A那么按照程序次序规则A happens-before B接着在B之后马上执行了C按照线程启动规则在新的线程启动之前当前线程之前的所有操作对新的线程是可见的所以 B happens-before C最后根据传递性规则由于A happens-before BB happens-before C所以A happens-before C因此在新的线程中会输出a修改后的结果10。