Java锁手册

零 致歉

  这篇博客内容有点多,图也没咋画代码也没贴就很长了,主要是为了把锁的来龙去脉讲清楚,所以更为具体的实现部分没有整理,如果大家在阅读的时候觉得哪里有问题,可以留言给我,我找时间把一些代码或者流程图补上。

  需要读者朋友注意的是,锁的分类仅仅是一种特性概念,Java的某种实现机制可能涵盖了多种特性,而且任何锁特性都是为了解决某种特殊问题而产生的,锁的特性又间接引出了其他问题,所以锁在不停的进化中,前后是有关联关系的。

  保证数据一致性、解决并发安全问题与系统性能损耗的平衡就是锁要考虑的核心原则。

一 前言

  国庆了,老光棍又开始了寂寞无聊的“慢慢”假期,做点啥?打游戏看书睡觉。其实平时工作的节奏是真的很快,再加上自己懒一些,所以真正有时间看看书、充充电的时间非常少,好不容易有了这么连续的时间,还是想多巩固下技能。

  我本身不是一个什么牛逼的技术大佬,也做不来很腻害的架构设计,所以我写的每一篇博客都是本着自己复习、巩固知识点,分享基础技能,给自己和新入门的朋友一些应用上的思路。

  节前两天在各个同事的工位上走了走,几乎人人桌上都放着几本书,他们自己看不看我不知道,反正我是都翻了翻,上一篇桥接模式的博客就是在其中一个同事桌上看到的,然后自己百度下,再结合现在的工作,最后弄出来《桥接模式于外发设计的应用思路》

  今天写写关于Java中的并发问题,其中一项很重要的技能点——锁。当然,前言中必须要澄清,所谓的锁其实大多是一种相对概念,在Java语法体系中,其实现并没有那么复杂。而且作为一名开发人员,更应该关注何时才需要应用锁,而不是如何应用锁。锁这种工具是为了解决并发问题,且权衡性能和并发安全性之后的一种折中解决方案。

  参考了网上的一些介绍资料,再结合自己看过的几本书《深入理解Java虚拟机》、《Java并发编程实战》,最后汇总出了下文内容,希望能给同道中人一些帮助。

  其中涉及并发问题和虚拟机(包括字节码文件规范)的部分,不会过多介绍,如果对这部分不太了解的道友可以自己买来书看看,或者简单的百度搜搜(注意:百度上搜索到的东西,不要直接装进自己的脑袋里,要多找几篇相互印证,有些网友单纯的复制粘贴,很多内容不经推敲的)。

二 锁分类

  Java中对锁的分类一般是按照锁的某种特性定义的,针对某种特殊的应用场景的具体实现中体现出了这种特性而已,所以在介绍锁分类的时候,没必要和实现咬合的太紧,先理解锁特性,再去看相应的实现,最后看其应用。

  何时需要锁?一定是为解决多线程并发操作共享资源时出现的线程安全问题。从这个角度看,已经可以对锁进行一些维度分类了,下表是从网络上搜集的信息整理而来的锁分类:

维度分支锁类型
是否需要锁资源需要悲观锁
是否需要锁资源不需要乐观锁
竞争锁失败时线程是否阻塞阻塞阻塞锁
竞争锁失败时线程是否阻塞不阻塞自旋锁/适应性自旋锁
竞争锁处理流程仅单线程访问公共资源无锁
竞争锁处理流程同线程访问公共资源偏向锁
竞争锁处理流程多线程竞争访问,竞争失败线程自旋等待获取锁轻量级锁
竞争锁处理流程多线程竞争访问,竞争失败线程阻塞等待唤醒重量级锁
竞争锁是否排队排队公平锁
竞争锁是否排队不排队非公平锁
多线程能够共用锁共用共享锁
多线程能够共用锁不共用排他锁

  到此为止,对几个基础概念在多做解释。

  什么是资源?这是一个很抽象的概念,我们可以认为任何可被其他程序访问的对象、对象属性、被static修饰的静态成员,甚至DB表中的数据、实体文件及文件内容等等等等,都可以被称之为资源。

  为什么公共资源的并发访问才会有并发安全问题?两个角度看这个问题。第一个是逻辑角度,如果一个资源的访问权限是非常低的,仅供某个线程使用(一夫一妻制哦,生下来的孩子一定是亲爹的),那么这个资源在任何时刻的状态,对这个线程来说都是可靠的、可预计的。

  举个例子,一个计数器,一个线程,这个计数器只被这个线程拥有,并且每秒钟这个线程对这个计数器+1,那么无论在什么时候,这个线程去访问计数器当前数值的时候,其结果都是可预知的、确定的,比如说10秒后计数器结果一定是10。

  之所以是这样的结果,原因来自第二个角度——实现机制,线程的执行过程是串行的,也就是说同一个线程在同一时间点,只能做一件事情(你不可能同时踏入两条河流,哇塞好哲学哟),它不可能在对计数器+1的同时在做点别的操作,比如说-1。

  正因为如此,单线程的操作,其结果的预知永远是能够做到的。但是!!!如果单线程变成了多线程,这就难说了。比如说还是上面的例子,只不过变成了两条线程对计数器每秒+1,那么10秒过后每个线程都认为计数器应该是10,而实际结果呢?连20都未必(恋爱一起谈,脚踏两条船,这是个不正经的计数器)。

  就是因为我们程序员在编写程序的时候也是串行的(卧槽我没见过并发编程的人,哲学使我眼瞎,请上帝教我怎么同时写出两行代码),所以我们对程序执行结果的预知基本上都来自于逻辑上的推演。

  一旦程序的执行过程不再是串行了,你的小脑袋是想象不出来程序的执行结果的(总有人问我,你自己写的程序你不知道会发生什么结果吗?我真不知道,而且我想骂你)。这也是为什么会出现锁的原因,将并行的逻辑强行转变为串行执行,使程序运行结果可以预知。

三 锁介绍

  章节二中提到的诸多锁类型,因其各自的特性而应用场景不同,这句话应该倒过来说,正是因为并发场景的不同,才出现了不同的锁类型,接下来对每种锁做一个简单介绍。

2.1 悲观锁

  悲观锁指线程在访问共享资源时,假想一定会有其他线程同时访问(意图不轨),所以必须加锁阻止其他线程的访问(像一只护食的狗子?)。典型的实现如synchronized、Lock,都是先对资源进行加锁,其后才对资源进行访问操作,流程如下:

悲观锁示意

2.2 乐观锁

  乐观锁则如其名字,对待资源的态度非常乐观,它认为当前线程在操作资源时不会出现其他线程的竞争,直接干就是了。

  所以乐观锁在操作资源之前(一般对资源进行更新操作)会重新查询资源状态,以获取最新的资源。那么如果真的发生了意外呢(指原资源与重新查询到的资源不一致时)?一般会采取重新尝试或者抛错处理。

  在Java中乐观锁是没有任何语法实现的,它是通过一种叫CAS算法来实现的,这部分介绍参见附录5.1 CAS算法及实现

2.3 阻塞锁

  阻塞锁的“阻塞”指的是这种锁会使当前线程进入阻塞状态,Java中synchronized(严格点说是重量级锁模式)、ReentrantLock、LockSupport.park()这些和锁相关的实现都是阻塞模式的,当然能够让线程进入阻塞状态的操作不仅于此,包括阻塞式的IO操作、网络通信(典型的同步短连接等)、Object.wait()等等,都会导致当前线程阻塞,关于线程的状态介绍可以参见附录5.2 线程状态

2.4 自旋锁

  自旋锁(SpinLock)是相对于阻塞锁的,这里需要铺垫一个事情,阻塞以及唤醒一个线程,这个过程是不受程序控制的,程序仅仅能表达一个美好愿望,能不能实现要看CPU给不给面子。

  那么随之而来的问题就是,CPU在进行线程切换时,受限于线程上下文的切换动作等,是需要消耗处理器的资源和时间的。如果一个锁动作的处理逻辑很简单,很可能会出现线程状态切换的耗时多于阻塞锁的处理逻辑产生的耗时,这是一件得不偿失的事情(所以并发安全问题归根结底在于权衡性能和并发安全性)。

  为此自旋锁出现了,它发现当前线程需要访问的资源已经被别的线程锁住了,那么它不会像阻塞锁一样放弃CPU时间片,而是原地打转,过一会再瞅瞅资源是否被释放,这就是自旋的由来(是不是可以叫炫迈锁????)。

  既然可以让线程不放弃CPU时间片来自旋等待被锁资源,是不是都采用这种锁就最好,显然不是,因为你占用了CPU资源。假如被锁资源很长时间都不得释放,这就意味着自旋等待的时间会很长,被占用的CPU这个时间段内做不了其他事情,什么最宝贵?CPU资源最宝贵,所以自旋锁仅适用于被锁资源能够很快释放的场景。

  那么问题来了,如果我采用了自旋锁,但是临界区的逻辑处理时间又比较长,这时候怎么办?不用担心,Java已经考虑到这个问题了,自旋不会一直持续的,它只有一段比较短的等待时间,如果自旋的次数超过了某个设定的数值,那么就将当前线程挂起,不再自旋了。这个参数值默认为10,通过“-XX:PreBlockSpin”来设置。

  自旋锁是在JDK1.4(据说是1.4.2,每深究过)中引入的,在JVM参数中通过“-XX:+UseSpinning”参数开启,在JDK1.6中则调整为自动开启。自旋锁典型的应用场景为原子类(concurrent包中的Atomit***)。

  值得一提的是JDK1.6中还引入了自适应的自旋锁(适应性自旋锁)。自适应是指自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

  如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。适应性自旋锁内容参考自java中的各种锁详细介绍,很遗憾这篇博文访问不到了。

  综上,自旋锁虽然能够解决阻塞问题带来的性能损耗,但是也引来了更多问题,因为自旋锁不仅存在占用CPU的缺陷,还存在公平性问题,以及访问性能问题,下面对自旋锁的一些缺陷引入其解决方案,首先介绍下TicketLock——排队锁(取号锁(一种公平锁)见章节2.9 公平锁)。

2.4.1 TicketLock

  取号锁是为了解决自旋锁公平性问题而产生的,它是如何做到的呢?实现过程包含两个号码值,临界区(临界区指从加锁到锁释放的中间代码片段,加锁处为入界,解锁处为出界,其间为临界区)持有一个服务号,等待加锁线程持有一个等待号,只有当服务号和等待号相同时等待线程才能进入临界区。

  我脑子的第一个反应就是:银行柜面业务的取号或者医院的挂号,所有等待线程就像是等待办理业务或者等待看病的病人,都在盯着叫号机的屏幕显示,等待叫到自己的取号码时,ok我进来咯。也正是因为如此,取号锁才能实现公平性问题(插队不道德哟)。

  这里面有一个要点,就是服务号一定是原子的,什么意思呢?想想看其他等待线程都在观察服务号当前的值,那么就要求任何一个线程在修改服务号码值的时候,对其他线程都必须是可见的(可见行,通过volatile关键字修饰)。而volatile的实现机制要求其数据内容必须刷入主存(道友们可百度下volatile的介绍,这关乎到JVM内存结构,更为详细的资料建议看看《深入理解Java虚拟机》),这个开销就大了。

  那么怎么解决频繁访问主存的性能消耗问题呢?引入了另一种自旋锁——CLHLock,见下文。

2.4.2 CLHLock

  这个我真不会翻译,只知道CLH分别是三个人的名字——Craig(克雷格), Landin(兰丁), and Hagersten(黑格斯滕)。但是原理比较好理解,TicketLock实现机制中所有等待线程都在监听主存中的服务号,性能开销大一些,那么为了解决这个问题——CLHLock出现了,它在自己的本地变量上自旋,不再频繁的从主存获取服务号的最新值。

  CLHLock的实现原理就是一个链表,每个等待线程在等待加锁时就创建一个节点,这个节点包含两个主要成员:

  1. locked标识,如果值为true,表示我要加锁,或者正在锁
  2. 前节点对象

  那么当一个线程需要加锁时,它就创建了节点(locked值为true),并加入到链表的尾巴上。然后不断在前节点对象上自旋,看前节点对象的locked值什么时候为false,只要前节点locked值为false了,它就可加锁访问资源了。等待自己访问结束,将自己节点的locked值改为false,以便后续线程加锁进入临界区。

  CLHLock的典型应用场景为AbstractQueuedSynchronizer。

  综上,CLHLock也是一种排队锁,很公平,先到先得,而且能解决频繁访问主存的性能消耗。但是!!!它依然有缺陷,缺陷来自于当前的系统环境。

  我们常规电脑的处理器结构为SMP(Symmetric Multi-Processor),称为对称多处理器结构,指服务器中多个CPU对称工作,每个CPU访问内存地址所需时间相同。SMP结构能够保证内存一致性,但这些共享的资源很可能成为性能瓶颈,随着CPU数量的增加,每个CPU都要访问相同的内存资源,可能会导致CPU资源的浪费。

  另一种处理器结构为NUMA(Non-Uniform Memory Access),非一致存储访问处理器结构,它将CPU分为多个模块,每个模块由多个CPU组成,并且具有独立的本地内存、I/O槽口等,模块之间可以通过互联模块相互访问,这样的设计使得访问本地内存的速度将远远高于访问远地内存(系统内其它节点的内存)的速度,这也是非一致存储访问的由来。NUMA较好地解决SMP的扩展问题,当CPU数量增加时,因为访问远地内存的延时远远超过本地内存,系统性能不会线性增加。

  假定系统的处理器结构为NUMA,就意味着每个线程有自己的内存,那么线程创建的排队节点中的前节点所在的内存位置不再当前组,每次通过自旋判断前趋结点的locked值都需要访问其他组的内存,这性能就非常低了,怎么办?见下文。

2.4.3 MCSLock

  我特么的哔哔哔……MCS还是三个名字,John M. Mellor-Crummey and Michael L. Scott,不翻译了,自己百度吧。这个锁就是为了解决NUMA处理器结构下的排队自旋锁性能问题而产生的。

  解决思路极其简单,CLHLock不是因为在前节点的locked值上自旋才出现的性能问题嘛,MCSLocak直接调整为当前线程在访问资源结束后,将后节点的locked值改为true,这样后面等待的线程只需要在自己节点的locked成员上转圈圈就好了。

  如此调整,就从多次访问前节点(可能在其他处理器组)的自选模式,变成了更新一次后节点,而后节点在自己的内存区域自旋就完了,跨组问题解决!

2.5 无锁

  介绍无锁之前,我必须要铺垫一个事情,就是无锁、偏向锁、轻量级锁以及重量级锁都是针对synchronized关键字的,所以这四个锁分类一定要一起看。对这部分内容更详细的介绍,请跳至章节4.3 无锁VS偏向锁VS轻量级锁VS中量级

  无锁的含义就是它的字面意思,不对资源加锁,所有的线程都能对资源进行访问操作,但是只有一个线程能操作成功。

  实现过程就是一个大循环,所有线程都在尝试更新资源,那么每次更新之前都在对资源状态进行重新查询,如果出现冲突(冲突指其他线程已经更新资源状态)就重新尝试,直到更新成功。

  说道这里有没有发现前文有提过?CAS算法的实现,不就是一种无锁实现咯。虽然无锁无法替代常规锁,但是性能高啊。

2.6 偏向锁

  偏向锁指的是如果临界区仅仅被同一个线程访问,那就自动让线程获得锁,省的每次线程都要为了加锁解锁来消耗性能,以此来提高执行效率。

  偏向锁的实现机制较为复杂,涉及到Java对象头和监视器对象Monitor,这部分内容参见附录5.3 Java对象头和Monitor

  当一个线程试图访问临界区并获取锁时,会在对象头Mark Wod和栈帧锁记录里存储锁偏向的线程ID,以后该线程在入界和出界时就不需要进行CAS操作来加锁和解锁,只需简单地查询Mark Word里是否存储着指向当前线程的偏向锁。

  如果的确存在,那就表示线程已经获得了锁。否则就再查询一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没设置,则使用CAS竞争锁(竞争什么?);如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。这里不能说太细了,涉及的底层细节太多,而且大多数场景用不到,下面再简单的说一下开启偏向锁的方法和一些缺陷,更细致的介绍建议道友们自己搜一下相关资料,再次强调理清楚锁的来龙去脉即可,真的不要太深入。

  通过jvm的参数-XX:UseBiasedLocking=true/false来开启和关闭偏向锁,默认(严格的说是JDK1.6及之后的版本)是开启的。

  偏向锁基本上只针对第一个线程有效,其他线程在尝试获取锁时会触发锁膨胀问题,这个问题会导致STW(Stop the world)。

  当关闭偏向锁时,会自动进入轻量级锁模式,见下文。

2.7 轻量级锁

  如果当前线程获取的锁类型是偏向锁的时候,此时其他线程也要访问临界区,那么偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。这是个锁升级的过程,记住锁只能向高层级升级,不会降级。

  在线程进入临界区的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

  如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。否则虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

  若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁,重量级锁见下文。

2.8 重量级锁

  当锁类型升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

  综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁则是将除了拥有锁的线程以外的线程都阻塞。

  所以说无锁、偏向锁、轻量级锁和重量级锁都是针对synchronized关键字而言的,只不过在不同的应用场景下,通过升级锁类型来最大程度的保证程序的执行效率。

2.9 公平锁

  公平锁的“公平”指的是多个线程按照申请锁的顺序来获取锁,这是一个排队的过程,上文中的TicketLock就是其中的一种,只有队列中的第一个线程才能获得锁。

  公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销就比较大。

  一切优缺点都是站在性能开销的基础上来讲的,把握住这条核心原则,那么在什么场景下用什么锁就很清晰了,这才是开发者最应该关注的。

  既然排队会导致CPU资源损耗,那么非公平锁则不存在这个问题,见下文。

2.10 非公平锁

  非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。

  所以说非公平锁依然是需要排队的,只不过如果当前线程在尝试获取锁时,刚好锁可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。这也是不公平的由来,不见得先到先得,虽然插队不好,但是可以减少唤起线程的开销,整体的吞吐效率高。

  不过也正是因为线程不用排队,就会自然的引出一个无法解决的弊端,处于等待队列中的线程可能等很久才会获得锁,甚至饿死,这就是竞争锁的饥饿问题。

2.11 可重入锁

  可重入锁也叫递归锁,是指同一个线程在进入某一个临界区的时候获取的锁,可以直接进入其他被相同锁修饰的其他临界区,不会因为之前已经获取过还没释放而阻塞。

  Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可以在一定程度避免死锁(因为锁相同,不需要等待前一个锁释放,也就不会造成死锁)。

  为了更好的解释避免死锁的原因,我简单写一段代码,未必精确,会意即可好不好:

synchronized void first() {
	second();
}

synchronized void second() {
	// TODO
}

  这里大家应该比较熟悉了,当前线程如果获取了被synchronized修饰的锁,那么同一个锁修饰的其他逻辑,当前线程是可以直接访问的,这就是重入。

  如果不能重入,那么当前线程在执行second方法的时候,就会阻塞等待first锁释放,这特么不就等死了……

  可重入锁的实现机制基本上就是个计数器,当前线程占用锁之后计数器+1,再次访问其他临界区继续+1,退出临界区则-1,直到计数器为0,则认为锁被释放,其他线程可重新竞争锁了。

2.12 不可重入锁

  不可重入锁则和重入锁完全相反,典型的实现为NonReentrantLock,因为实现机制相对简单,所以执行效率稍微高一些,但是实际上可用的应用场景真不多。

  不可重入锁的实现机制依然是计数器,但是用法则不一样,这里不知道有没有朋友们好奇,这个计数器在哪里呢?很简单ReentrantLock和NonReentrantLock这两个锁实现的基类都是AbstractQueuedSynchronizer,就是这个父类中维护这个计数值,而两者的实现差异就在于如何使用这个计数值:

  当线程尝试获取锁时,可重入锁先尝试获取并更新计数值,如果计数值为0则表示没有其他线程进入临界区,那么把计数值+1,当前线程进入临界区。如果计数值不是 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行计数值+1,且当前线程可以再次获取锁。

  不可重入锁更简单,它直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞,完事儿。

  释放锁时,可重入锁先查询计数值,如果计数值-1后为0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。不可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。

2.13 独占锁

  独占锁有很多别名,独享锁、排他锁、互斥锁,是指该锁一次只能被一个线程所持有。Java中的synchronized和Lock的实现类就是互斥锁。

2.14 共享锁

  共享锁是相对于独占锁的概念,指该锁可被多个线程所持有。

  共享锁必须要澄清一个问题,共享锁没有单独出现的,一定是某一个线程对资源加锁了,并且在读写资源,那么其他线程如果可以对资源加锁,也只能加共享锁,并且共享锁仅允许其他线程读资源,而不能修改。

  Java中的ReentrantReadWriteLock就一种共享锁实现,建议道友们阅读下源码,非常简单,通过读写锁来实现独占或是共享,这里不贴了。

四 锁对比

4.1 悲观锁VS乐观锁

  1. 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
  2. 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

4.2 自旋锁VS适应性自旋锁

  适应性自旋锁的自旋次数不再固定而已。

4.3 无锁VS偏向锁VS轻量级锁VS中量级

  1. 无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
  2. 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
  3. 是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
  4. 升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

  并且在四种锁模式中,对象头的存储内容也不一样:

锁模式头内容(Mark Word)标识位
无锁对象的hashcode,对象分代年龄,不可偏向标识01
偏向锁线程ID,偏向时间戳,对象分代年龄,偏向标识01
轻量级锁栈桢中锁记录引用00
重量级锁互斥量的引用10

  有一个好玩的东西,就是标识位为11的时候会发生什么?GC。

4.4 公平锁VS非公平锁

  公平锁就是通过排队队列来实现多个线程按序取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后后发先至的情况。

4.5 可重入锁VS不可重入锁

  实现机制不同,主要是对计数器的判断处理逻辑不同。

4.6 共享锁VS排他锁

  主要在于对共享资源的操作权限不同,排他锁可读写,而共享锁只读。

五 附录

5.1 CAS算法及实现

  CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。

  CAS算法涉及到三个操作数:

  1. 需要读写的内存值 V
  2. 进行比较的值 A
  3. 要写入的新值 B。

  算法过程:

  1. 当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作)
  2. 否则不会执行任何操作。

  Java中的CAS操作都是通过本地方法Unsafe()实现的,读者可自行查阅其他资料,需要注意的是CAS虽然解决阻塞带来性能消耗问题,但是也引入了ABA问题。

  ABA问题是指如线程1从内存X中取出A,这时候另一个线程2也从内存X中取出A,并且线程2进行了一些操作将内存X中的值变成了B,然后线程2又将内存X中的数据变成A,这时候线程1进行CAS操作发现内存X中仍然是A,然后线程1操作成功。

  虽然线程1的CAS操作成功,但是整个过程就是有问题的,虽然结果值相同,但是数据状态已经发生了变化,那么怎么解决呢?

  Java中提供了AtomicStampedReference/AtomicMarkableReference来处理会发生ABA问题的场景,主要是在对象中额外再增加一个标记来标识对象是否有过变更。

5.2 线程状态

  1. 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
  2. 可运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“可运行状态”。
  3. 阻塞(BLOCKED):表示线程阻塞于锁。
  4. 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
  5. 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
  6. 终止(TERMINATED):表示该线程已经执行完毕。

5.3 Java对象头和Monitor

  每一个 Java 对象都至少占用 2 个字宽的内存,数组类型占用3个字宽。字宽是一种内存大小的单位,在32 位处理器中一个字宽四个字节,64位处理器一个字宽八个字节。

  第一个字宽也被称为对象头Mark Word。

  第二个字宽是指向定义该对象类信息(class metadata)的指针。

  其中锁相关内容就在第一个字宽中,网上找的一个图(非数组类型的对象头):

非数组对象头结构

  每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。

  每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

  更为详细的介绍建议阅读《深入了解Java虚拟机》。

六 结语

  如果想关注更多硬技能的分享,可以参考积少成多系列传送门,未来每一篇关于硬技能的分享都会在传送门中更新链接。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柠檬睡客

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值