最近在学习jvm,发现随着对虚拟机底层的了解,对java的多线程也有了全新的认识,原来一个小小的synchronized关键字里别有洞天。决定把本身关于java多线程的所学整理成一篇文章,从最基础的为何使用多线程,一直深刻讲解到jvm底层的锁实现。java
为何要使用多线程?能够简单的分两个方面来讲:缓存
其实多线程根本的问题只有一个:线程间变量的共享安全
java里的变量能够分3类:数据结构
下图是jvm的内存区域划分图:多线程
根据各个区域的定义,咱们能够知道:并发
“方法区”和“堆”都属于线程共享数据区,“虚拟机栈”属于线程私有数据区。jvm
所以,局部变量是不能多个线程共享的,而类变量和实例变量是能够多个线程共享的。事实上,在java中,多线程间进行通讯的惟一途径就是经过类变量和实例变量。性能
也就是说,若是一段多线程程序中若是没有类变量和实例变量,那么这段多线程程序就必定是线程安全的。学习
以Web开发的Servlet为例,通常咱们开发的时候,本身的类继承HttpServlet以后,重写doPost()、doGet()处理请求,无论咱们在这两个方法里写什么代码,只要没有操做类变量或实例变量,最后写出来的代码就是线程安全的。若是在Servlet类里面加了实例变量,就极可能出现线程安全性问题,解决方法就是把实例变量改成ThreadLocal变量,而ThreadLocal实现的含义就是让实例变量变成了“线程私有”的,即给每个线程分配一个本身的值。优化
如今咱们知道:其实多线程根本的问题只有一个:线程间变量的共享,这里的变量,指的就是类变量和实例变量,后续的一切,都是为了解决类变量和实例变量共享的安全问题。
如今惟一的问题就是要让多个线程安全的共享变量(下文中的变量通常特指类变量和实例变量),上文提到了一种ThreadLocal的方式,其实这种方式并非真正的共享,而是为每一个线程分配一个本身的值。
好比如今有一个特别简单的需求,有一个类变量a=0,如今启动5个线程,每一个线程执行a++;若是用ThreadLocal的方式,最后的结果就是5个线程都拥有一份本身的a值,最终结果都是1,这显然不符合咱们的预期。
那么若是不使用ThreadLocal呢?直接声明一个类变量a=0,而后让5个线程分别去执行a++;这样结果依旧不对,并且结果是不肯定的,多是1,2,3,4,5中的任一个。这种状况叫作竞态条件(Race Condition),要理解竞态条件先要理解Java内存模型:
要理解java的内存模型,能够类比计算机硬件访问内存的模型。因为计算机的cpu运算速度和内存io速度有几个数量级的差距,所以现代计算机都不得不加入一层尽量接近处理器运算速度的高速缓存来作缓冲:将内存中运算须要使用的数据先复制到缓存中,当运算结束后再同步回内存。以下图:
由于jvm要实现跨硬件平台,所以jvm定义了本身的内存模型,可是由于jvm的内存模型最终仍是要映射到硬件上,所以jvm内存模型几乎与硬件的模型同样:
每一个java线程都有一份本身的工做内存,线程访问变量的时候,不能直接访问主内存中的变量,而是先把主内存的变量复制到本身的工做内存,而后操做本身工做内存里的变量,最后再同步给主内存。
如今就能够解释为何5个线程执行a++最后结果不必定是5了,由于a++能够分解为3步操做:
而5个线程并发执行的时候彻底有可能5个线程都先执行了第一步,这样5个线程的工做内存里a的初始值都是0,而后执行a=a+1后在工做内存里的运算结果都是1,最后同步回主内存的值确定也是1。
而避免这种状况的方法就是:在多个线程并发访问a的时候,保证a在同一个时刻只被一个线程使用。
同步(synchronized)就是:在多个线程并发访问共享数据的时候,保证共享数据在同一个时刻只被一个线程使用。
为了保证共享数据在同一时刻只被一个线程使用,咱们有一种很简单的实现思想,就是在共享数据里保存一个锁,当没有线程访问时,锁是空的,当有第一个线程访问时,就在锁里保存这个线程的标识并容许这个线程访问共享数据。在当前线程释放共享数据以前,若是再有其余线程想要访问共享数据,就要等待锁释放。
咱们把这种思想的三个关键点抽出来:
能够说jvm中的三种锁都是以上述思想为基础的,只是实现的“重量级”不一样,jvm中有如下三种锁(由上到下愈来愈“重量级”):
其中重量级锁是最初的锁机制,偏向锁和轻量级锁是在jdk1.6加入的,能够选择打开或关闭。若是把偏向锁和轻量级锁都打开,那么在java代码中使用synchronized关键字的时候,jvm底层会尝试先使用偏向锁,若是偏向锁不可用,则转换为轻量级锁,若是轻量级锁不可用,则转换为重量级锁。具体转换过程下面会讲。
要想深刻了解这3种锁须要了解对象的内存结构(MarkWord头),会涉及到字节码的内部存储格式,可是其实我以为脱离细节的实现,单从原理上理解这三个锁是很容易的,只须要了解两个大致的概念:
MarkWord:java中的每一个对象在存储的时候,都有统一的数据结构。每一个对象都包含一个对象头,称为MarkWord,里面会保存关于这个对象的加锁信息。
Lock Record: 即锁记录,每一个线程在执行的时候,会有本身的虚拟机栈,当个方法的调用至关于虚拟机栈里的一个栈帧,而Lock Record就位于栈帧上,是用来保存关于这个线程的加锁信息。
最初jvm没有前两种锁(前两种都是jdk1.6才引入的),只有重量级锁。
咱们以前给出了同步基本思想的三个点,咱们也说了jvm的三种锁都是以基本思想为基础的,而这三种锁在第一、2点的实现上本质上是同样的:
而区分这三种锁的关键,就是同步基本思想的第三点:
3.其余线程访问已加锁共享数据要等待锁释放
这里的等待锁释放是一个抽象的说法,并无严格要求怎么等待。而重量级锁由于使用了互斥量,这里的等待就是线程阻塞。使用互斥量能够保证全部状况下的并发安全,可是使用互斥量会带来较大的性能消耗。并且在实际的项目代码中,极可能一段原本不会有并发状况的代码被加了锁,这样每次使用互斥量就白白消耗了性能。能不能先假设被加锁的代码不会有并发的状况,等到发现有并发的时候再使用互斥量呢?答案是能够的,轻量级锁和偏向锁都是基于这种假设来实现的。
轻量级锁的核心思想就是“被加锁的代码不会发生并发,若是发生并发,那就膨胀成重量级锁(膨胀指的锁的重量级上升,一旦升级,就不会降级了)”。
轻量级锁依赖了一种叫作CAS(compare and swap)的操做,这个操做是由底层硬件提供相关指令实现的:
CAS操做须要3个参数,分别是内存位置V,旧的指望值A和新值B。CAS指令执行时,当且仅当V当前值符合旧值A时,处理器用新值B更新V的值,不然不执行更新。上述过程是一个原子操做。
假设如今开启了轻量级锁,当第一个线程要锁定对象时,该线程首先会在栈帧中创建Lock Record(锁记录)的空间,用于存储对象目前MarkWord的拷贝,而后虚拟机将使用CAS操做尝试将对象的MarkWord更新为指向线程锁记录的指针。若是操做成功,则该线程得到对象锁。若是失败,说明在该线程拷贝对象当前MarkWord以后,执行CAS操做以前,有其余线程获取了对象锁,咱们最开始的假设“被加锁的代码不会发生并发”失效了。此时轻量级锁还不会直接膨胀为重量级锁,线程会自旋不停地重试CAS操做寄但愿于锁的持有线程主动释放锁,在自旋必定次数后若是仍是没有成功得到锁,那么轻量级锁要膨胀为重量级锁:以前成功获取了轻量级锁的那个线程如今依旧持有锁,只是换成了重量级锁,其余尝试获取锁的线程进入等待状态。
轻量级锁的解锁也是用CAS来操做,若是对象的MarkWord中依然是持有锁线程的锁记录指针,则CAS成功,把锁记录中的原MarkWord的拷贝复制回去,解锁完成;若是对象的MarkWord中保存的再也不是持有锁线程的锁记录指针,说明在持有锁线程持有锁期间,这个轻量级锁已经由于其它线程并发获取膨胀为了重量级锁,所以线程在释放锁的同时,还要唤醒(notify)等待的线程。
偏向锁
根据轻量级锁的实现,咱们知道虽然轻量级锁不支持“并发”,遇到“并发”就要膨胀为重量级锁,可是轻量级锁能够支持多个线程以串行的方式访问同一个加锁对象。好比A线程能够先获取对象o的轻量锁,而后A释放了轻量锁,这个时候B线程来获取o的轻量锁,是能够成功获取得,以这种方式能够一直串行下去。之因此能实现这种串行,是由于有一个释放锁的动做。那么假设有一个加锁的java方法,这个方法在运行的时候其实从始至终只有一个线程在调用,可是每次调用完却也要释放锁,下次调用还要从新得到锁。
那么咱们能不能作一个假设:“假设加锁的代码从始至终就只有一个线程在调用,若是发现有多于一个线程调用,再膨胀成轻量级锁也不迟”。这个假设,就是偏向锁的核心思想。
偏向锁的核心实现很简单:假设开启了偏向锁,当第一个线程尝试得到对象锁的时候,也会在栈帧中创建Lock Record锁记录,可是这个Lock Record空间不须要初始化(后面会用到它),而后直接用CAS将本身的线程ID写到对象的MarkWord里,若是CAS操做成功,就获取了偏向锁。线程获取偏向锁后即使是执行完加锁的代码块,也会一直持有锁不会主动释放。所以这个线程之后每次进入这个锁相关的代码块的时候,都不须要执行任何额外的同步操做。
当有另一个线程尝试得到锁的时候,须要进行revoke操做,分状况讨论:
上面的第3点基本是照着官方文档翻译的,看了一些书、博客,对这块都说的不明白。
如下是我本身的理解:
一个已经持有偏向锁的线程,再次进入这个锁相关的代码块的时候,虽然不须要执行额外的同步操做,可是依旧会在栈上生成一个空的LockRecord,所以对于一个重入了几回对象锁的线程来讲,栈中就有了关联同一个对象的多个LockRecord。
并且jvm运行时里,会记录着加锁的次数,每重入一次,就+1;当每次要解锁的时候,首先会把加锁次数-1,只有当加锁次数减到0的时候,才真正的去执行加锁操做。这个是参考了monitorexit字节码的解释来的:
Note that a single thread can lock an object several times - the runtime system maintains a count of the number of times that the object was locked by the current thread, and only unlocks the object when the counter reaches zero .
而加锁次数减到0的时候,此时对应的锁记录确定是第一次加锁的锁记录,也就是“最老的”,所以须要把“最老的”锁记录的指针写到对象的MarkWord里,这样当执行轻量级锁解锁的CAS操做的时候就可以成功解锁了。)
从上述偏向锁核心实现咱们能够看出来,当访问一个对象锁的只有一个线程时,偏向锁确实很快,可是一旦有第二个线程来访问,就可能要膨胀为轻量级锁,膨胀的开销是很大的。
因此咱们会有一个想法:若是在要给一个对象加偏向锁的时候,能提早知道这个对象会是由单个线程访问仍是多个线程访问就行了。那么怎么知道一个没有被访问过的对象是否是仅会被单线程访问呢?咱们知道每一个对象都有对应的类,咱们能够经过和这个对象同属一个类(data type)的其余对象被访问的状况来推测这个对象将要被访问的状况。
所以咱们能够从data type的维度来批量操做这个data type下的全部对象的偏向锁:
其实抛开实现的细节,java的多线程很简单:
java多线程主要面临的问题就是线程安全问题 --》
线程安全问题是由线程间的通讯形成的,多个线程间不通讯就没有线程安全问题--》
java中线程通讯只能经过类变量和实例变量,所以解决线程安全问题就是解决对变量的安全访问问题--》
java中解决变量的安全访问采用的是同步的手段,同步是经过锁实现的--》
有三种锁能保证变量只有一个线程访问,偏向锁最快可是只能用于从始至终只有一个线程得到锁,轻量级锁较快可是只能用于线程串行得到锁,重量级锁最慢可是能够用于线程并发得到锁,先用最快的偏向锁,每次假设不成立就升级一个重量。