【转载】Java中的锁机制 synchronized & 偏向锁 & 轻量级锁 & 重量级锁 & 各自优缺点及场景 & AtomicReference

参考文章: http://blog.csdn.net/chen77716/article/details/6618779

目前在Java中存在两种锁机制:synchronized和Lock,Lock接口及其实现类是JDK5增加的内容,其作者是大名鼎鼎的并发专家Doug Lea。本文并不比较synchronized与Lock孰优孰劣,只是介绍二者的实现原理。

数据同步需要依赖锁,那锁的同步又依赖谁?synchronized给出的答案是在软件层面依赖JVM,而Lock给出的方案是在硬件层面依赖特殊的CPU指令。

synrhronized使用广泛。其应用层的语义是可以把任何一个非null对象作为"锁",
当synchronized作用在方法上时,锁住的便是对象实例(this);
当作用在静态方法时锁住的便是对象对应的Class实例,因为Class数据存在于永久带,因此静态方法锁相当于该类的一个全局锁;
当synchronized作用于某一个对象实例时,锁住的便是对应的代码块。

在HotSpot JVM实现中,锁有个专门的名字:对象监视器。

线程状态及状态转换

复制代码
当多个线程同时请求某个对象监视器时,对象监视器会设置几种状态用来区分请求的线程:
Contention List:所有请求锁的线程将被首先放置到该竞争队列
Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
Owner:获得锁的线程称为Owner
!Owner:释放锁的线程
复制代码

 

上图和文章中提到了ContentionList,又提到了EntryList。

自旋锁

还提到了自旋锁(Spin Lock),在一个线程获取锁的时候,先进行自旋,尝试。虽然对ContentionList中的线程不尽公平,但是效率可以大大提升。

复制代码
那些处于ContetionList、EntryList、WaitSet中的线程均处于阻塞状态,阻塞操作由操作系统完成(在Linxu下通过pthread_mutex_lock函数)。
线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能 缓解上述问题的办法便是自旋,其原理是:
当发生争用时,若Owner线程能在很短的时间内释放锁,则那些正在争用线程可以稍微等一等(自旋),在Owner线程释放锁后,争用线程可能会立即得到锁,
从而避免了系统阻塞。
但Owner运行的时间可能会超出了临界值,争用线程自旋一段时间后还是无法获得锁,这时争用线程则会停止自旋进入阻塞状态(后退)。
基本思路就是自旋,不成功再阻塞,尽量降低阻塞的可能性,这对那些执行时间很短的代码块来说有非常重要的性能提高。
自旋锁有个更贴切的名字:自旋-指数后退锁,也即复合锁。很显然,自旋在多处理器上才有意义。
复制代码

自旋锁详细介绍:

复制代码
还有个问题是,线程自旋时做些啥?其实啥都不做,可以执行几次for循环,可以执行几条空的汇编指令,目的是占着CPU不放,等待获取锁的机会。
所以说,自旋是把双刃剑,如果旋的时间过长会影响整体性能,时间过短又达不到延迟阻塞的目的。
显然,自旋的周期选择显得非常重要,但这与操作系统、硬件体系、系统的负载等诸多场景相关,很难选择,
如果选择不当,不但性能得不到提高,可能还会下降,因此大家普遍认为自旋锁不具有扩展性。 对自旋锁周期的选择上,HotSpot认为最佳时间应是一个线程上下文切换的时间,但目前并没有做到。
经过调查,目前只是通过汇编暂停了几个CPU周期,除了自旋周期选择,HotSpot还进行许多其他的自旋优化策略,
具体如下:
如果平均负载小于CPUs则一直自旋 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞 如果CPU处于节电模式则停止自旋 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差) 自旋时会适当放弃线程优先级之间的差异
那synchronized实现何时使用了自旋锁?
答案是在线程进入ContentionList时,也即第一步操作前。
线程在进入等待队列时首先进行自旋尝试获得锁,如果不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平。
还有一个不公平的地方是自旋线程可能会抢占了Ready线程的锁。自旋锁由每个监视对象维护,每个监视对象一个。
复制代码

偏向锁

偏向锁(Biased Lock)主要解决无竞争下的锁性能问题.

首先我们看下无竞争下锁存在什么问题:
现在几乎所有的锁都是可重入的,也即已经获得锁的线程可以多次锁住/解锁监视对象,
按照之前的HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用,
因此偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免CAS操作,
说白了就是置个变量,如果发现为true则无需再走各种加锁/解锁流程。

以上内容来自 http://blog.csdn.net/chen77716/article/details/6618779 但是没怎么读懂 

在搜索偏向锁的过程中,又找到下面这篇

http://blog.163.com/silver9886@126/blog/static/35971862201472274958280/

Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。

偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
(偏向锁只能在单线程下起作用) 因此 流程是这样的 偏向锁->轻量级锁->重量级锁

其中还提到轻量级锁和重量级锁。那么这还涉及锁膨胀。

通过知乎上的一篇回答 https://www.zhihu.com/question/39009953?sort=created

轻量级锁就是为了在无多线程竞争的环境中使用CAS来代替mutex,一旦发生竞争,两条以上线程争用一个锁就会膨胀。

回到前一篇

锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。
在32位虚拟机中,一字宽等于四字节,即32bit。 锁状态包括:轻量级锁定、重量级锁定、GC标记、可偏向
  • 这篇文章 http://blog.csdn.net/wolegequdidiao/article/details/45116141
    • 对象自身的运行时数据 
      如:哈希吗(HashCode)、GC分代年龄(Generational GC Age)等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,简称“Mark Word”

下面两张图可以先忽略,因为后面有更清楚的

(忽略图,看后面的)32位JVM的Mark Word的默认存储结构如下: 

 (忽略图,看后面的)64位JVM下, Mark Word是64bit大小的,存储结构如下: 

简单的加锁机制:

机制:每个锁都关联一个请求计数器和一个占有他的线程,当请求计数器为0时,这个锁可以被认为是unhled的,
当一个线程请求一个unheld的锁时,JVM记录锁的拥有者,并把锁的请求计数加1,如果同一个线程再次请求这个锁时,请求计数器就会增加,
当该线程退出syncronized块时,计数器减1,当计数器为0时,锁被释放(这就保证了锁是可重入的,不会发生死锁的情况)。

偏向锁流程:

偏向锁,简单的讲,就是在锁对象的对象头中有个ThreaddId字段,这个字段如果是空的,
第一次获取锁的时候,就将自身的ThreadId写入到锁的ThreadId字段内,将锁头内的是否偏向锁的状态位置1.
这样下次获取锁的时候,直接检查ThreadId是否和自身线程Id一致,如果一致,则认为当前线程已经获取了锁,因此不需再次获取锁,
略过了轻量级锁和重量级锁的加锁阶段。提高了效率。
但是偏向锁也有一个问题,就是当锁有竞争关系的时候,需要解除偏向锁,使锁进入竞争的状态。

下面是清晰的流程:

上图中只讲了偏向锁的释放,其实还涉及偏向锁的抢占,其实就是两个进程对锁的抢占,在synchrnized锁下表现为轻量锁方式进行抢占。

注:也就是说一旦偏向锁冲突,双方都会升级为轻量级锁。(这一点与轻量级->重量级锁不同,那时候失败一方直接升级,成功一方在释放时候notify,加下文后面详细描述)

如下图。之后会进入到轻量级锁阶段,两个线程进入锁竞争状态(注,我理解仍然会遵守先来后到原则;注2,的确是的,下图中提到了mark word中的lock record指向堆栈中最近的一个线程的lock record),一个具体例子可以参考synchronized锁机制。(图后面有介绍)

上面163的文章中,提到了这一篇 http://xly1981.iteye.com/blog/1766224,里面对于synchronized的过程讲的挺好:

复制代码
每一个线程在准备获取共享资源时: 
第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 
第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,
之前线程将Markword的内容置为空。 第三步,两个线程都把对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作,
把共享对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord, 第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己
复制代码
总结:
偏向锁,其实是无锁竞争下可重入锁的简单实现。流程是这样的 偏向锁->轻量级锁->重量级锁

 

发现,这篇文章对于synchronized讲得比较清楚 http://www.infoq.com/cn/articles/java-se-16-synchronized

先介绍一下CAS:

Compare and Swap

比较并设置。用于在硬件层面上提供原子性操作。在 Intel 处理器中,比较并交换通过指令cmpxchg实现。
比较是否和给定的数值一致,如果一致则修改,不一致则不修改。

同步的基础

Java中的每一个对象都可以作为锁。

  • 对于同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前对象的Class对象。
  • 对于同步方法块,锁是Synchonized括号里配置的对象。

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。那么锁存在哪里呢?锁里面会存储什么信息呢?

同步的原理

JVM规范规定JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。

代码块同步是使用monitorenter和monitorexit指令实现,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是方法的同步同样可以使用这两个指令来实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。

任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

Java对象头

锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。(下面这个表格讲的很清楚)

 

长度

内容

说明

32/64bit

Mark Word

存储对象的hashCode或锁信息等。

32/64bit

Class Metadata Address

存储到对象类型数据的指针

32/64bit

Array length

数组的长度(如果当前对象是数组)

 

Java对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下:

 

25 bit

4bit

1bit

是否是偏向锁

2bit

锁标志位

无锁状态

对象的hashCode

对象分代年龄

0

01

在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据: 

锁状态

25 bit

4bit

1bit

2bit

23bit

2bit

是否是偏向锁

锁标志位

轻量级锁

指向栈中锁记录的指针

00

重量级锁

指向互斥量(重量级锁)的指针

10

GC标记

11

偏向锁

线程ID

Epoch

对象分代年龄

1

01

上图里面的GC标记,为11的话,推断应该是准备GC的意思。

在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:  

 

锁状态

25bit

31bit

1bit

4bit

1bit

2bit

 

 

cms_free

分代年龄

偏向锁

锁标志位

无锁

unused

hashCode

 

 

0

01

偏向锁

ThreadID(54bit) Epoch(2bit)

 

 

1

01

锁的升级

Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,

所以在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。

锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。

这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析。

偏向锁

复制代码
Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,
以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,
如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,
则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。 偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),
它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,
如果线程不处于活动状态,则将对象头设置成无锁状态,
如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,
栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

上面的意思是,先暂停持有偏向锁的线程,尝试直接切换。如果不成功,就继续运行,并且标记对象不适合偏向锁,锁膨胀(锁升级)。
详见,上面有张图中的“偏向锁抢占模式”:
其中提到了mark word中的lock record指向堆栈最近的一个线程的lock record,其实就是按照先来后到模式进行了轻量级的加锁。
复制代码

上文提到全局安全点:在这个时间点上没有字节码正在执行。

关闭偏向锁:偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,

如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。

如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。

轻量级锁

轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。

然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁解锁:轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。

如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

注:轻量级锁会一直保持,唤醒总是发生在轻量级锁解锁的时候,因为加锁的时候已经成功CAS操作;而CAS失败的线程,会立即锁膨胀,并阻塞等待唤醒。(详见下图)

下图是两个线程同时争夺锁,导致锁膨胀的流程图。

锁不会降级

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。
当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

锁的优缺点对比 

优点

缺点

适用场景

偏向锁

加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。

如果线程间存在锁竞争,会带来额外的锁撤销的消耗。

适用于只有一个线程访问同步块场景。

轻量级锁

竞争的线程不会阻塞,提高了程序的响应速度。

如果始终得不到锁竞争的线程使用自旋会消耗CPU。

追求响应时间。

同步块执行速度非常快。

重量级锁

线程竞争不使用自旋,不会消耗CPU。

线程阻塞,响应时间缓慢。

追求吞吐量。

同步块执行速度较长。

 上面这张表格好好看,总结的非常好!

6 参考源码

对象头源码markOop.hpp。偏向锁源码biasedLocking.cpp。以及其他源码ObjectMonitor.cpp和BasicLock.cpp。

 

上面这篇文章(http://www.infoq.com/cn/articles/java-se-16-synchronized)讲得非常清晰了。下面还有几篇文章之前打开了,看是否有补充。

 

http://blog.csdn.net/wolegequdidiao/article/details/45116141

这篇文章提到:

轻量级锁加锁进行的CAS操作中,是先更新Lock Record指针,然后再更新最后2bit的锁标记位(也不一定,没有明确说。但是各种锁的顺序要一致;注:涉及偏向锁,很可能是先改锁标记位的)。

下面这张图,和上面那张图是一致的:

总结

轻量级锁能提高程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

偏向锁

JDK1.6引入

    • 优点:消除数据在无竞争情况下的同步原语,提高性能。
    • 偏向锁与轻量级锁理念上的区别: 
      • 轻量级锁:在无竞争的情况下使用CAS操作去消除同步使用的互斥量
      • 偏向锁:在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了
    • 意义:锁偏向于第一个获得它的线程。如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
    • 相关参数: 
      • 默认-XX:+UseBiasedLocking=true
      • -XX:-UseBiasedLocking=false关闭偏向锁
      • 应用程序启动几秒钟之后才激活
      • -XX:BiasedLockingStartupDelay = 0关闭延迟

注意:偏向锁的锁标记位和无锁是一样的,都是01,但是有单独一位偏向标记设置是否偏向锁。

再复习一下,轻量级锁00,重量级锁10,GC标记11,无锁 01.  

下面这张图做一个复习:

总结

偏向锁可以提高带有同步但无竞争的程序性能。如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。

在具体情形分析下,禁止偏向锁优反而可能提升性能。

 

看这篇文章的一些笔记 http://www.cnblogs.com/javaminer/p/3889023.html

复制代码
在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、
偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。 锁消除(Lock Elimination):通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,
通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。 轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态
(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,
取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。
当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。 偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,
因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟(可参考这篇文章)。 适应性自旋(Adaptive Spinning):当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁
(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁),
进入到阻塞状态。
复制代码

注:(适应性)自旋锁,是在从轻量级锁向重量级锁膨胀的过程中使用的,是在进入重量级锁之前进行的。

复制代码
轻量级锁具体实现:

一个线程能够通过两种方式锁住一个对象:1、通过膨胀一个处于无锁状态(状态位001)的对象获得该对象的锁;
2、对象已经处于膨胀状态(状态位00)但LockWord指向的monitor record的Owner字段为NULL,
则可以直接通过CAS原子指令尝试将Owner设置为自己的标识来获得锁。 从中可以看出,是先检查锁的标识位。
复制代码

看下面这篇文章的记录:

 http://www.cnblogs.com/javaminer/p/3892288.html?utm_source=tuicool&utm_medium=referral 

偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令。

其他,这篇文章也没什么需要注意的内容了。

这时候,我突然想到,为什么CAS就不能对标识位和数据一起操作呢,一次操作完成是否可以呢?

CAS应用

CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

复制代码
下面从分析比较常用的CPU(intel x86)来解释CAS的实现原理。

 下面是sun.misc.Unsafe类的compareAndSwapInt()方法的源代码:

public final native boolean compareAndSwapInt(Object o, long offset,
                                              int expected,
                                              int x);
可以看到这是个本地方法调用。这个本地方法在openjdk中依次调用的c++代码为:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。
复制代码

对于32位/64位的操作应该是原子的:

奔腾6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器不能自动保证其原子性,
比如跨总线宽度,跨多个缓存行,跨页表的访问。但是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

CAS的缺点

复制代码
CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作

1.  ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,
那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。
在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。 从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。
这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,
则以原子方式将该引用和该标志的值设置为给定的更新值。 关于ABA问题参考文档: http://blog.hesey.net/2011/09/resolve-aba-by-atomicstampedreference.html 2. 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,
pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,
延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。 3. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,
但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,
或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。
从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
复制代码

AtomicReference类的学习可以参考以下

http://www.cnblogs.com/skywang12345/p/3514623.html

简单源码示例如下(已经实际实验验证):

复制代码
// AtomicReferenceTest.java的源码
import java.util.concurrent.atomic.AtomicReference;

public class AtomicReferenceTest {
    
    public static void main(String[] args){

        // 创建两个Person对象,它们的id分别是101和102。
        Person p1 = new Person(101);
        Person p2 = new Person(102);
        // 新建AtomicReference对象,初始化它的值为p1对象
        AtomicReference ar = new AtomicReference(p1);
        // 通过CAS设置ar。如果ar的值为p1的话,则将其设置为p2。
        ar.compareAndSet(p1, p2);

        Person p3 = (Person)ar.get();
        System.out.println("p3 is "+p3);
        System.out.println("p3.equals(p1)="+p3.equals(p1));
    }
}

class Person {
    volatile long id;
    public Person(long id) {
        this.id = id;
    }
    public String toString() {
        return "id:"+id;
    }
}
复制代码

运行结果:

p3 is id:102
p3.equals(p1)=false

 

以上,是关于synchronized, 偏向锁,轻量级锁,重量级锁,自旋锁,CAS等的一些内容和笔记。

后续还会再对JVM以及Java的一些机制和实现做更多学习。

比如会新开一篇关于JVM的文章,

http://www.cnblogs.com/charlesblc/p/5996268.html

JVM 对 Java 的原生锁做了哪些优化?

自旋锁

在 Java 6 之前,Monitor 的实现完全依赖底层操作系统的互斥锁来实现。
由于 Java 层面的线程与操作系统的原生线程有映射关系,如果要将一个线程进行阻塞或唤起都需要操作系统的协助,这就需要从用户态切换到内核态来执行,这种切换代价十分昂贵,很耗处理器时间,现代 JDK 中做了大量的优化。
一种优化是使用自旋锁,即在把线程进行阻塞操作之前先让线程自旋等待一段时间,可能在等待期间其他线程已经解锁,这时就无需再让线程执行阻塞操作,避免了用户态到内核态的切换。

Java 虚拟机的开发工程师们在分析过大量数据后发现:共享数据的锁定状态一般只会持续很短的一段时间,为了这段时间去挂起和恢复线程其实并不值得。

自旋锁在 JDK 1.4 中引入,在 JDK 1.6 中默认开启。

自旋等待虽然避免了线程切换的开销,但自旋的线程要占用处理器时间的,所以若锁被占用的时间很短,自旋等待的效果就会非常好,反之锁被占用的时间很长,那么自旋的线程只会白白消耗 CPU 资源。

因此自旋等待的时间必须要有一定的限度,超过限定的次数仍然没有成功获得锁,就应当挂起(阻塞)线程了。自旋次数的默认值是 10 次。

自适应自旋锁

在 JDK 1.6 中引入了自适应自旋锁。

自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。

如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

锁消除

在动态编译同步块的时候,JIT 编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。从而取消对这部分代码的同步。

锁消除:指虚拟机即时编译器在运行时,对一些代码上要求同步,但被检测到不可能存在共享数据竞争的锁进行消除。主要根据逃逸分析。
程序员怎么会在明知道不存在数据竞争的情况下使用同步呢?很多不是程序员自己加入的。

锁粗化

当 JIT 编译器发现一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中的时候,会将加锁同步的范围扩散(粗化)到整个操作序列的外部。

在编写代码的时候,总是推荐将同步块的作用范围(锁粒度)限制得尽量小(只在共享数据的实际作用域中才进行同步),这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程可以尽快的拿到锁。 

锁粒度:不要锁住一些无关的代码。锁粗化:可以一次执行完的不要多次加锁执行

Synchronized,ReentrantLock的实现原理及异同比较 、Java中其他锁的理解

1)Synchronized 同步锁


作用域:不同加锁方式对应不同作用域

1) 对象锁

当一个对象访问Synchronized方法时,锁类型为对象锁。
此时该对象不能同时访问类中其他Synchronized方法。若一个对象中有多个synchronized方法,只要一个线程访问了其中的一个synchronized方法,则其它线程不能同时访问这个对象中任何一个synchronized方法。
此外,不同对象实例的synchronized方法是不相干预的。也就是说,其它线程可以同时访问此类下的另一个对象实例中的synchronized方法;

2) 类锁 :对该类的所有对象实例起作用。

当对静态方法加锁时,锁类型为类锁。相当于给类加锁(synchronized.Class),静态方法被调用时(类名.方法名),会先检测调用类有没有持有锁,持有锁时,等待。

锁的底层实现:

使用一个monitor对象,由ObjectMonitor实现(C++代码)
在这里插入图片描述

Class字节码中
1)对象加锁时,使用monitorenter指令得到monitor对象。检查owner是否为空
在这里插入图片描述
monitorenter操作的目标一定是对象,类型是reference。Reference实际就是堆里的一个存放对象的地址。每个对象(reference)都有一个monitor对应。
2)方法加锁时
常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
在这里插入图片描述

注:

1)类锁与对象锁互不干扰。
	 个人推测原因:
	 静态方法:与静态成员变量一样,属于类的本身,在类装载的时候被装载到内存,
	 非静态方法:又叫实例化方法,属于实例对象,实例化后才会分配内存,必须通过类的实例来引用。
	 不同地址,所以访问互不干扰

2)wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,
  否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

3)Synchronized缺点:当一个线程持有锁时,不能立即返回,也不能被打断。其他线程只能阻塞。

2)ReentrantLock


ReentrantLock :核心为AQS(AbstractQueueSynchronzer)。AQS基于FIFO双向队列实现。
队列的基本元素是Node,AQS为每个线程创建一个Node进行调度

ReentrantLock根据构造方法中传入的boolean值来创建一个静态抽象内部类Sync的子类,即公平锁或非公平锁
公平锁与非公平锁的比较
不公平锁:可能会产生饥饿现象。效率较高
公平锁:不会产生饥饿现象,效率较低
在这里插入图片描述

ReentrantLock内部类结构

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述在这里插入图片描述

线程调用lock()方法获取锁的过程

1)	使用CAS操作尝试修改当前同步状态。
	a)	状态为0时,可直接修改AQS状态为1,成功得到锁
		i.	将AQS的父类中exclusiveOwnerThread 设为当前线程(表示当前线程为独占拥有线程)
	b)	状态不为0时,判断当前线程是否和执行线程Owner一致。
		i.	一致时,state+1(锁的重入)
		ii.	不一致时,调用addWaiter(Node.EXCLUSIVE)方法。为线程创建node节点
		iii.当队列为空时,调用enq方法。方法内部自旋,首先判断尾是否为空
			1.为空时,创建一个空节点。赋head = tail
			2.方法内自旋,此时尾不为空,将节点插入尾部
		iv.	调用方法,设置Node中waitState值为-1,标志该Node中线程被取消。
		v.	调用parkAndCheckInterrupt()方法,方法内部park(this),将线程状态由Runnable,修改为waiting

线程调用unlock()方法释放锁的过程

先调用release()方法。
	1)	方法内部调用tryrelease()方法
		a.	tryrelease():对state-1操作。此时若state为0,返回true。否则返回false.
	2)	当返回true时,进入if方法体,首先判断队列的head != null 且head.waitStatus != 0(当前实际值为-1),则调用unparkSuccessor(Node)方法 
		a.	unparkSuccessor(Node)方法内部。
			i.	修改waitState状态为0,
			ii.	调用unpark(thread)方法,使线程由Waiting变为Runnable

3)Synchronized与ReentrantLock的异同

同:
	1)都为可重入锁:同一个线程每进入一次,锁的计数器都自增1,要等到锁的计数器下降为0时才能释放锁
	2)优化思想:都试图在用户态就把加锁问题解决,避免进入内核态的线程阻塞。
	
异:
	1)Synchronized是依赖于JVM实现的(部分为C++代码),而ReenTrantLock是JDK实现的(Java代码实现)
	2)ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。
 	3)ReenTrantLock提供了一个Condition(条件)类,可实现分组唤醒线程,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。
	4)ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。synchronized不可中断

4)对象实例结构 Java中各种锁的理解


对象实例结构

分为三部分:对象头、实例数据、对齐填充
Header(对象头):
	1)存放自身运行时数据: 哈希值、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
	2)类型指针:虚拟机通过该指针确定对象是哪个类的实例
InstanceData(实例数据):对象存储的有效信息,即字段内容
Padding(对齐填充):占位符。Java要求对象大小为8字节的整数倍,当数据没有对齐时,使用对齐填充

在这里插入图片描述

公平锁与非公平锁

公平锁:多个线程等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁
非公平锁:在锁释放时,随机指定一个线程得到锁。
Synchronized为非公平锁,
ReentrantLock默认为非公平锁,但可通过带Boolean值的构造函数来指定使用公平锁

自旋锁与自适应自旋

背景:
1.许多应用中,共享数据的锁定常常为短暂持有。
2.挂起线程和恢复线程的操作都需要转入内核态中完成。系统高并发时,大量挂起和恢复十分影响性能。
3.当一个机器中有多个处理器,可以使多个线程并发执行时,我们可以让后一个请求锁的线程。执行一个短暂自循环(继续占用处理器),看看持有锁的线程是否会很快释放锁。

自旋锁:短暂占用处理器,节省挂起和恢复线程的资源。
自适应自旋锁:自旋的时间不再固定,而是根据持有相同锁的前一个线程的自旋时间和锁的状态来决定。

轻量级锁:

目的:在没有的多线程的竞争下,减少 重量级锁使用操作系统中互斥量的开销
实现:使用CAS操作修改对象头中标志位。

00表示持有锁
01表示未锁定。
10表示膨胀为重量级锁
当两个以上线程竞争时,轻量级锁不再有效,会膨胀为重量级锁

优点:轻量级锁使用CAS操作标志位,避免了重量级锁使用互斥量的开销
缺点:当存在锁竞争时,除了互斥量开销外,还有CAS操作,因此在存在竞争的情况下,轻量级锁比重量级锁更慢

偏向锁:

目的:消除数据在无竞争情况下的同步原语,进一步提升程序运行性能。
实现:对于持有偏向锁的线程,在接下来程序的执行过程中,如果该锁没有被其他线程获取,则永远不进行同步操作。

轻量级锁在无竞争情况下,使用CAS操作。
偏向锁在无竞争情况下,直接消除同步,不做任何操作。

当程序总是存在多线程竞争的情况下,可通过禁止偏向锁优化以提升性能
使用参数-XX:-UseBiaseLocking

reentrantlock :已经实现偏向锁。
1.在加锁时,会判断是否为当前持锁线程(使用TreadID 或 Thread本身来进行判断)
2.是当前持锁线程时,不进行CAS操作,直接对count+1。减少了一次CAS操作锁带来的性能开销。

可重入锁ReentrantLock及其他显式锁相关问题

1、跟Synchronized相比,可重入锁ReentrantLock其实现原理有什么不同?

其实,锁的实现原理基本是为了达到一个目的:让所有的线程都能看到某种标记。
Synchronized通过在对象头中设置标记实现了这一目的,是一种JVM原生的锁实现方式,而ReentrantLock以及所有的基于Lock接口的实现类,都是通过用一个volitile修饰的int型变量,并保证每个线程都能拥有对该int的可见性和原子修改,其本质是基于所谓的AQS框架。

2、 AQS框架是怎么回事儿?

AQS(AbstractQueuedSynchronizer类)是一个用来构建锁和同步器的框架,各种Lock包中的锁(如ReentrantLock、ReadWriteLock),以及其他如Semaphore、CountDownLatch,甚至是早期的FutureTask等,都是基于AQS来构建。
①、AQS在内部定义了一个volatileintstate变量,表示同步状态:当线程调用lock方法时,如果state=0,说明没有任何线程占有共享资源的锁,可以获得锁并将state=1;如果state=1,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
②、AQS通过Node内部类构成的一个双向链表结构的同步队列,来完成线程获取锁的排队工作,当有线程获取锁失败后,就被添加到队列末尾。
Node类是对要访问同步代码的线程的封装,包含了线程本身及其状态叫waitStatus(有五种不同取值,分别表示是否被阻塞,是否等待唤醒,是否已经被取消等),每个Node结点关联其prev结点和next结点,方便线程释放锁后快速唤醒下一个在等待的线程,是一个FIFO的过程。
Node类有两个常量,SHARED和EXCLUSIVE,分别代表共享模式和独占模式。所谓共享模式是一个锁允许多条线程同时操作(信号量Semaphore就是基于AQS的共享模式实现的),独占模式是同一个时间段只能有一个线程对共享资源进行操作,多余的请求线程需要排队等待(如ReentranLock)。
③、AQS通过内部类ConditionObject构建等待队列(可有多个),当Condition调用wait()方法后,线程将会加入等待队列中,而当Condition调用signal()方法后,线程将从等待队列转移动同步队列中进行锁竞争。
④、AQS和Condition各自维护了不同的队列,在Condition的时候,其实就是两个队列的互相移动。

3、Synchronized和ReentrantLock的异同

ReentrantLock是Lock的实现类,是一个互斥的同步锁。
从功能角度,ReentrantLock比Synchronized的同步操作更精细甚至实现Synchronized没有的高级功能,
如:等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,对处理执行时间非常长的同步块很有用。
带超时的获取锁尝试:在指定的时间范围内获取锁,如果时间到了仍然无法获取则返回。可以判断是否有线程在排队等待获取锁。可以响应中断请求:与Synchronized不同,当获取到锁的线程被中断时,能够响应中断,中断异常将会被抛出,同时锁会被释放。可以实现公平锁。
从锁释放角度,Synchronized在JVM层面上实现的,不但可以通过一些监控工具监控Synchronized的锁定,而且在代码执行出现异常时,JVM会自动释放锁定;但是使用Lock则不行,Lock是通过代码实现的,要保证锁定一定会被释放,就必须将unLock()放到finally{}中。
从性能角度,Synchronized早期实现比较低效,对比ReentrantLock大多数场景性能都相差较大。
但是在Java6中对其进行了非常多的改进,在竞争不激烈时,Synchronized的性能要优于ReetrantLock;在高竞争情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态。

4、ReentrantLock是如何实现可重入性的?

ReentrantLock内部自定义了同步器Sync(Sync既实现了AQS,又实现了AOS,而AOS提供了一种互斥锁持有的方式),其实就是加锁的时候通过CAS算法,将线程对象放到一个双向链表中,每次获取锁的时候,看下当前维护的那个线程ID和当前请求的线程ID是否一样,一样就可重入了。

5、除了ReetrantLock,你还接触过JUC中的哪些并发工具?

通常所说的并发包(JUC)也就是java.util.concurrent及其子包,集中了Java并发的各种基础工具类,具体主要包括几个方面:
提供了CountDownLatch、CyclicBarrier、Semaphore等,比Synchronized更加高级,可以实现更加丰富多线程操作的同步结构。
提供了ConcurrentHashMap、有序的ConcunrrentSkipListMap,或者通过类似快照机制实现线程安全的动态数组CopyOnWriteArrayList等,各种线程安全的容器。提供了ArrayBlockingQueue、SynchorousQueue或针对特定场景的PriorityBlockingQueue等,各种并发队列实现。强大的Executor框架,可以创建各种不同类型的线程池,调度任务运行等。

6、如何让Java的线程彼此同步?你了解过哪些同步器?请分别介绍下。

JUC中的同步器三个主要的成员:CountDownLatch、CyclicBarrier和Semaphore,通过它们可以方便地实现很多线程之间协作的功能。
①、CountDownLatch叫倒计数,允许一个或多个线程等待某些操作完成。用法:CountDownLatch构造方法指明计数数量,被等待线程调用countDown将计数器减1,等待线程使用await进行线程等待。
②、CyclicBarrier叫循环栅栏,它实现让一组线程等待至某个状态之后再全部同时执行,而且当所有等待线程被释放后,CyclicBarrier可以被重复使用。CyclicBarrier的典型应用场景是用来等待并发线程结束。
CyclicBarrier的主要方法是await(),await()每被调用一次,计数便会减少1,并阻塞住当前线程。当计数减至0时,阻塞解除,所有在此CyclicBarrier上面阻塞的线程开始运行。在这之后,如果再次调用await(),计数就又会变成N-1,新一轮重新开始,这便是Cyclic的含义所在。CyclicBarrier.await()带有返回值,用来表示当前线程是第几个到达这个Barrier的线程。
③、Semaphore,Java版本的信号量实现,用于控制同时访问的线程个数,来达到限制通用资源访问的目的,其原理是通过acquire()获取一个许可,如果没有就等待,而release()释放一个许可。如果Semaphore的数值被初始化为1,那么一个线程就可以通过acquire进入互斥状态,本质上和互斥锁是非常相似的。但是区别也非常明显,比如互斥锁是有持有者的,而对于Semaphore这种计数器结构,虽然有类似功能,但其实不存在真正意义的持有者,除非我们进行扩展包装。

7、CyclicBarrier和CountDownLatch看起来很相似,请对比下呢?

它们的行为有一定相似度,区别主要在于:
•CountDownLatch是不可以重置的,所以无法重用,CyclicBarrier没有这种限制,可以重用。
•CountDownLatch的基本操作组合是countDown/await,调用await的线程阻塞等待countDown足够的次数,不管你是在一个线程还是多个线程里countDown,只要次数足够即可。CyclicBarrier的基本操作组合就是await,当所有的伙伴都调用了await,才会继续进行任务,并自动进行重置。
CountDownLatch目的是让一个线程等待其他N个线程达到某个条件后,自己再去做某个事(通过CyclicBarrier的第二个构造方法publicCyclicBarrier(intparties,RunnablebarrierAction),在新线程里做事可以达到同样的效果)。而CyclicBarrier的目的是让N多线程互相等待直到所有的都达到某个状态,然后这N个线程再继续执行各自后续(通过CountDownLatch在某些场合也能完成类似的效果)。

posted @ 2021-02-20 15:31  CharyGao  阅读(704)  评论(0编辑  收藏  举报