全方位刨析Synchronized锁升级

线程安全?

当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行,这种方式叫互斥锁,即能达到互斥访问目的的锁,也就是说当一个共享数据被当前正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放该锁。在 Java 中,关键字syn可以保证在同一个时刻,只有一个线程可以执行某个方法或某个代码块。

定义

三大特性:可重入性、重量级、保证原子性,可见性和有序性。

图片.png

可见性:完全可以替代Volatile。

互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。互斥性我们也往往称为操作的原子性。

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁升级是单向的,只能从低到高升级,不会出现锁的降级。

synchronized 的用法

1、修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。

2、修饰实例方法(对象锁),是给调用这个方法的对象加锁,进入同步代码前要获得当前对象实例的锁。

3、修饰静态方法(类锁),是给当前类class对象加锁,作用于该类的所有对象,进入同步代码前要获得当前类class对象的锁。
注意:同类中syn修饰的多个静态方法加的锁会互斥

对象锁和类锁

对象锁:修饰非静态方法、synchronized(this|object) {}
在Java中,每个对象都会有一个 monitor 对象,这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。类的对象可以有多个,所以每个对象有其独立的对象锁,互不干扰。

类锁:修饰静态方法、synchronized(类.class) {}
在Java中,针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个 Class 对象,所以每个类只有一个类锁。

小结:
1、只要采用类锁,就会拦截所有线程,只能让一个线程访问。
2、如果对象锁跟访问的对象没有关系,那么就会都同时访问。
3、对于对象锁(this),如果是同一个实例,就会按顺序访问,但是如果是不同实例,就可以同时访问。

底层实现:

图片.png

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。

实例变量:

存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

填充数据:

由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

Java头对象

它是实现syn锁对象的基础,syn使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其结构是由Mark Word 和 Class Metadata Address 组成。

Mark Word(标记字段):
存储对象自身运行时数据信息;如:对象hashCode、GC分代年龄、GC标志、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。它是实现轻量级锁和偏向锁的关键.

Class Metadata Address(类型指针、Klass Pointer):
指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:

图片.png
什么是Monitor?

管程对象、对象监视器、可以理解为一个同步工具或一种同步机制;每个对象的对象头中都存在一个对应的monitor(对象头Address的指针指向monitor对象),对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

正因为每个对象都存在一个对应的monitor,所以Java中任意对象都可以作为锁,也解释了notify/notifyAll/wait等方法存在于顶级对象Object中的原因。

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 ;
    _previous_owner_tid = 0;
}

当多个线程同时请求某个对象监视器时,对象监视器会设置几种状态用来区分请求的线程:

Contention List:所有请求锁的线程将被首先放置到该竞争队列
Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
Owner:获得锁的线程称为Owner
!Owner:释放锁的线程

当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor锁并复位变量的值,以便其他线程进入获取monitor锁。

图片.png

新请求锁的线程将首先被加入到ConetentionList中,当某个拥有锁的线程(Owner状态)调用unlock之后,如果发现EntryList为空则从ContentionList中移动线程到EntryList中。

ContentionList虚拟队列

ContentionList并不是一个真正的Queue,而只是一个虚拟队列,原因在于ContentionList是由Node及其next指针逻辑构成,并不存在一个Queue的数据结构。ContentionList是一个先进先出(FIFO)的队列,每次新加入Node时都会在队头进行,通过CAS改变第一个节点的的指针为新增节点,同时设置新增节点的next指向后续节点,而取得操作则发生在队尾。显然,该结构其实是个Lock-Free的队列。
因为只有Owner线程才能从队尾取元素,也即线程出列操作无争用,当然也就避免了CAS的ABA问题。

EntryList

EntryList与ContentionList逻辑上同属等待队列,ContentionList会被线程并发访问,为了降低对ContentionList队尾的争用,而建立EntryList。Owner线程在unlock时会从ContentionList中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为Ready(OnDeck)线程。Owner线程并不是把锁传递给OnDeck线程,只是把竞争锁的权利交给OnDeck,OnDeck线程需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在Hotspot中把OnDeck的选择行为称之为“竞争切换”。
OnDeck线程获得锁后即变为owner线程,无法获得锁则会依然留在EntryList中,考虑到公平性,在EntryList中的位置不发生变化(依然在队头)。如果Owner线程被wait方法阻塞,则转移到WaitSet队列;如果在某个时刻被notify/notifyAll唤醒,则再次转移到EntryList。

重量级锁

java 6之前syn都是重量级锁,锁标识位为10,
JVM 是通过进入、退出对象监视器(Monitor) 来实现对方法、同步块的同步的,而对象监视器的本质依赖于底层操作系统的互斥锁(Mutex Lock) 实现。
对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程monitor.exit之后才能尝试继续获取锁。

syn代码块底层实现

具体实现是在编译之后在同步方法调用前加入一个monitor.enter指令,在退出方法和异常处插入monitor.exit的指令。上代码:

public static void main(String[] args) {
    synchronized (Demo02.class){
        System.out.println("Synchronize");
    }
}

编译后字节码:
public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                
       2: dup
       3: astore_1
       4: monitorenter          // 进入同步方法
       5: getstatic     #3                  
       8: ldc           #4                 
      10: invokevirtual #5                
      13: aload_1
      14: monitorexit          // 退出同步方法
      15: goto          23
      18: astore_2
      19: aload_1
      20: monitorexit          // 退出同步方法
      21: aload_2
      22: athrow
      23: return
    Exception table:
       from    to  target type
           5    15    18   any
          18    21    18   any

从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的计数器为 0,那线程可以成功取得 monitor,并将计数器值设为 1也就是加1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (锁的重入性),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行的线程monitorexit指令被执行,才会释放monitor锁(计数器值设为0) ,其他线程才能尝试获取 monitor锁 。

注意:字节码中为什么多了一个monitorexit指令?
编译器需要确保方法中调用过的每条monitorenter指令都要执行对应的monitorexit 指令。为了保证在方法异常时,monitorenter和monitorexit指令也能正常配对执行,编译器会自动产生一个异常处理器,处理所有异常,目的就是用来执行异常的monitorexit指令。所以多出的monitorexit就是异常结束时,用来释放monitor的。

syn方法底层实现

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现:

public class Demo03 {
    public int i;
    public synchronized void syncTask(){
        i++;
    }
    public static void main(String[] args) {
        new Demo03().syncTask();
    }
}


编译后的字节码:
public synchronized void syncTask();
    descriptor: ()V
    // ACC_PUBLIC表示public修饰,ACC_SYNCHRONIZED表示该方法为同步方法
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
        stack=3, locals=1, args_size=1
            0: aload_0
            1: dup
            2: getfield      #2                  // Field i:I
            5: iconst_1
            6: iadd
            7: putfield      #2                  // Field i:I
            10: return
        LineNumberTable:
            line 8: 0
            line 9: 10
        LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0      11     0  this   Lcom/test/demo/test/Demo03;

从字节码中可以看出,syn修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确是ACC_SYNCHRONIZED标识,JVM通过ACC_SYNCHRONIZED标识来辨别方法是否为同步方法,从而执行相应的同步调用。

缺点:

重量级锁效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对较长的时间(代价相对较高)。

JDK1.6优化

为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。

轻量级锁

这时Mark Word的结构也变为轻量级锁的结构,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少重量锁产生的性能消耗。

轻量锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量锁就会膨胀为重量锁。

轻量锁加锁过程

1、在代码进入同步块时,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。

2、拷贝对象头中的Mark Word复制到锁记录中。

3、拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向object mark word。如果更新成功则执行步骤(4),否则执行步骤(5)。

4、如果这个更新动作成功,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。

5、如果这个更新操作失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁。若当前只有一个等待线程,则可通过自旋稍微等待一下,可能另一个线程很快就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁就要膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

轻量锁解锁过程

1、通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
2、如果替换成功,整个同步过程完成。
3、如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就在释放锁的同时,唤醒被挂起的线程。

可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false。

缺点:

在个别场景,轻量锁的获取及释放产生的多次CAS原子指令是多余的。

偏向锁

偏向锁可以避免轻量锁多次CAS操作产生的性能消耗,偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令。

如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作就能获取锁。即获取锁的过程,省去了锁申请操作产生的性能消耗,但是对于多条线程使用同一把锁的场景,偏向锁就失效了。但不会立即膨胀为重量级锁,而是先升级为轻量级锁。

获取过程

1、一个对象刚开始实例化,没有任何线程来访问它的时候,它是可偏向的。当第一个线程来访问它的时候,它会偏向这个线程。这个线程会通过CAS将对象头的Mark Word偏向锁标识修改为“1”,锁标志位修改为“01”,并将对象头中的ThreadID改成自己的ID。之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。
2、当第二个线程竞争该锁时,会检查对象头中偏向锁的标识是否为“1”,锁标志位是否为“01”。如果是偏向锁,就判断线程ID是否指向当前线程。
-- 如果是则执行同步代码;
-- 如果不是则表明这个对象上已经存在竞争了。就检查持有偏向锁的线程是否存活(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁)。
---- 如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。
---- 如果原来的线程依然存活,则执行那个线程的操作栈,检查该对象的使用情况。
------ 如果仍然需要持有偏向锁,则偏向锁升级为轻量锁(标志位为“00”)。此时轻量锁由原持有偏向锁的线程继续持有,执行其同步代码。而正在竞争的线程会进入自旋等待获得该轻量级锁;
------ 如果不存在使用了,则可以将对象设置成无锁状态,然后重新偏向(通过CAS竞争锁,竞争成功则将Mark Word中线程ID设置为当前线程ID)。

偏向锁释放

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁、轻量锁和重量锁区别?

偏向所锁,轻量级锁都是乐观锁,重量级锁是悲观锁。
轻量锁适应的场景是线程近乎交替执行同步块的情况,如果存在同一时间访问同一锁的情况,轻量锁就会膨胀为重量级锁。

1、一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。

2、一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁;如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。

3、轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量锁会将拥有锁线程以外的线程都阻塞,防止CPU空转。

其他优化

自旋锁:

轻量锁升级为重量锁后,直接挂起线程会产生线程之间的切换(从用户态转换到核心态),性能消耗高。所以,JVM为了避免线程挂起,会让线程自旋尝试获取锁。当获取锁的线程执行完释放锁后,当前线程便可以获得执行权。因为在大多数情况下,线程持有锁的时间都不会太长。

缺点:自旋是需要消耗CPU资源的,长时间自旋会白白浪费CPU资源。

适应性自旋锁:

针对自旋锁的缺点,JDK1.6 加入了适应性自旋。
自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定。如果自旋成功了,那么下次自旋次数会更加多,因为JVM认为既然上次成功了,那么此次自旋也很有可能会成功,那么它就会允许自旋等待持续的次数更多。如果某个锁自旋很少成功获得,那么下一次就会减少自旋甚至省略掉自旋过程,以免浪费处理器资源。

通过--XX:+UseSpinning参数来开启自旋(JDK1.6之前默认关闭自旋)。
通过--XX:PreBlockSpin修改自旋次数,默认值是10次。

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,将不可能存在共享资源竞争的锁,将这种锁消除掉。可以节省毫无意义的请求锁时间。锁消除的依据是逃逸分析的数据支持。比如:

public void apply(String str1){
    StringBuffer sb = new StringBuffer();
    for(int i = 0 ; i < 10 ; i++){
        sb.append(str1);
    }
    System.out.println(sb);
}

因为StringBuffer是线程安全的,sb还只在方法内使用,不会被其他线程引用
sb又是不可能共享的资源,所以JVM会自动消除加的锁
锁粗化:

在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的作用域中才进行同步,这样做目的是为了使需要同步的操作数尽量少,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。

锁粗话就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。JVM检测到对同一个对象连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作。即加锁解锁操作会移到for循环之外,类似mysql行锁转表锁。

扩展

Synchronized 和 ReenTrantLock 的对比

1、两者都是可重入锁。

“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

2、synchronized依赖于JVM实现,而ReenTrantLock依赖于API。

前面我们也讲到了虚拟机团队在JDK1.6为syn关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReenTrantLock是JDK层面实现的(也就是API层面,需要lock()和unlock()方法配合try/finally语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

3、ReenTrantLock比synchronized增加了一些高级功能

1、等待可中断;
ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。

2、可实现公平锁;
ReenTrantLock可以指定是公平锁还是非公平锁。而syn只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReenTrantLock默认情况是非公平的,可以通过ReenTrantLoc类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。

3、可实现选择性通知(锁可以绑定多个条件);
syn关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify/notifyAll()方法进行通知时,被通知的线程是由JVM选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而syn关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法只会唤醒注册在该Condition实例中的所有等待线程。

性能已不是选择标准

在JDK1.6之前,syn的性能是比ReenTrantLock差很多。具体表示为:syn关键字吞吐量随线程数的增加,下降得非常严重。而ReenTrantLock基本保持一个比较稳定的水平。在JDK1.6之后JVM团队对syn关键字做了很多优化,性能基本能与ReenTrantLock持平。所以JDK1.6之后,性能已经不是选择syn和ReenTrantLock的影响因素,而且虚拟机在未来的性能改进中会更偏向于原生的syn,所以还是提倡在syn能满足你的需求的情况下,优先考虑使用syn关键字来进行同步!优化后的syn和ReenTrantLock一样,在很多地方都是用到了CAS操作。

CAS的原理是通过不断的比较内存中的值与旧值是否相同,如果相同则将内存中的值修改为新值,相比于syn省去了挂起线程、恢复线程的开销。

CAS的操作参数:内存位置(A)、预期原值(B)、预期新值(C)。

使用CAS解决并发的原理:
1. 首先比较A、B,若相等,则更新A中的值为C、返回True;若不相等,则返回false;
2. 通过死循环,以不断尝试尝试更新的方式实现并发

// 伪代码如下
public boolean compareAndSwap(long memoryA, int oldB, int newC){
    if(memoryA.get() == oldB){
        memoryA.set(newC);
        return true;
    }
    return false;
}

具体使用当中CAS有个先检查后执行的操作,而这种操作在Java中是典型的不安全的操作,所以CAS在实际中是由C++通过调用CPU指令实现的。

具体过程:
1、CAS在Java中的体现为Unsafe类。
2、Unsafe类会通过C++直接获取到属性的内存地址。
3、接下来CAS由C++的Atomic::cmpxchg系列方法实现。

AtomicInteger的 i++ 与 i-- 是典型的CAS应用,通过compareAndSet & 一个死循环实现。

private volatile int value; 

/** 
* Gets the current value. 
* @return the current value 
*/ 
public final int get() { 
   return value; 
} 

/** 
* Atomically increments by one the current value. 
* @return the previous value 
*/ 
public final int getAndIncrement() { 
   for (;;) { 
       int current = get(); 
       int next = current + 1; 
       if (compareAndSet(current, next)) 
           return current; 
   } 
} 

/** 
* Atomically decrements by one the current value. 
* @return the previous value 
*/ 
public final int getAndDecrement() { 
   for (;;) { 
       int current = get(); 
       int next = current - 1; 
       if (compareAndSet(current, next)) 
           return current; 
   } 
}
Synchronized 与 ThreadLocal 的对比

Synchronized关键字主要解决多线程共享数据同步问题;ThreadLocal主要解决多线程中数据因并发产生不一致问题。

Synchronized是利用锁的机制,使变量或代码块只能被一个线程访问。而ThreadLocal为每一个线程都提供变量的副本,使得每个线程访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。

线程中断

中断线程(实例方法)
public void interrupt();

判断线程是否被中断(实例方法)
public boolean isInterrupted();

判断是否被中断并清除当前中断状态(静态方法)
public static boolean interrupted();
用法:

处于运行期且非阻塞的状态的线程,直接调用interrupt()中断线程方法是不会得到任何响应的,如下代码:

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
        while(true)
            System.out.println("中断----");
    });
    t.start();
    TimeUnit.SECONDS.sleep(2);
    t.interrupt();
}

输出结果:
    中断----
    中断----
    中断----
......
这时你肯定会觉得interrupt()方法不管用??

其实是因为interrupt()方法需要配合isInterrupted()方法一起使用;interrupt()方法只会将线程中断状态重置,处于非阻塞状态的线程需要使用isInterrupted()方法进行中断检测:

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(){
        @Override
        public void run(){
            while(true)
                // 当前线程是否被中断
                if (this.isInterrupted()){
                    System.out.println("线程中断状态:" + this.isInterrupted());
                    System.out.println("清除中断状态是否成功:" + Thread.interrupted());
                    System.out.println("线程中断状态:" + this.isInterrupted());
                    break;
                }
            System.out.println("已跳出循环,线程中断!");
        }
    };
    t.start();
    TimeUnit.SECONDS.sleep(2);
    t.interrupt();
}

输出结果:
    线程中断状态:true
    清除中断状态是否成功:true
    线程中断状态:false
    已跳出循环,线程中断!

当一个线程处于被阻塞状态或者试图执行一个阻塞操作时,使用interrupt()方法中断该线程,此时将会抛出一个InterruptedException的异常,同时中断状态不会被改变(状态仍是非中断状态):

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread() {
        @Override
        public void run() {
            try {
                while(true)
                    // 当前线程处于阻塞状态,异常捕捉处理
                    TimeUnit.SECONDS.sleep(100);
            } catch (InterruptedException e) {
                // 写业务代码
                // 中断状态复位
                System.out.println("线程是否被中断:" + this.isInterrupted());
            }
        }
    };
    t.start();
    TimeUnit.SECONDS.sleep(2);
    // 中断阻塞状态的线程
    t.interrupt();
}

输出结果:
    线程是否被中断:false

小结:
一种是当线程处于阻塞状态或者试图执行一个阻塞操作时,我们可以使用interrupt()进行线程中断,执行中断操作后将会抛出interruptException异常(该异常必须捕捉无法向外抛出)并将中断状态复位;

另外一种是当线程处于运行状态时,用interrupt()进行线程中断,但必须手动判断中断状态,并编写中断线程的代码(结束run方法体的代码)。

工作中我们肯定得兼顾两种情况:

public static void main(String[] args) {
    new Thread(() -> {
        try {
            // 判断当前线程是否已中断,interrupted方法执行后会对中断状态进行复位
            while (!Thread.interrupted()) {
                // 业务代码。。。
                TimeUnit.SECONDS.sleep(2);
            }
        } catch (InterruptedException e) {
            // 有人在中断该线程
        }
    });
}
线程中断以为这样就完了么??

正在等待获取锁对象的syn方法或者代码块;调用interrupt()线程中断方法是不起作用的,因为对于syn来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就一直等待,这个时候调用中断线程的方法,也不会生效。

public class Demo08 implements Runnable{
    public synchronized void apply() {
        System.out.println("apply方法执行了");
        while(true)
            // 线程让步
            Thread.yield();
    }
    // 在构造器中创建新线程并启动获取对象锁
    public Demo08() {
        new Thread(() -> {
            apply();
        }).start();
    }
    public void run() {
        try{
            while (true)
                // 中断判断
                if (Thread.interrupted()) {
                    System.out.println("中断线程。。。。。");
                    break;
                } else {
                    apply();
                }
        } catch (Exception e) {
            System.out.println(e);
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Demo08 sync = new Demo08();
        Thread t = new Thread(sync);
        // 启动后会调apply()方法,但是会抢不到锁处于等待状态
        t.start();
        TimeUnit.SECONDS.sleep(2);
        //中断线程,无法生效
        t.interrupt();
    }
}


输出结果:
    apply方法执行了

上述代码中,我们在构造函数中创建一个线程调用apply()获取到当前实例锁,由于Demo08自身也是线程,启动后在其run方法中也调用了apply(),但由于对象锁被其他线程占用,导致t线程只能等待获取锁,这时我们调interrupt()方法是不能中断线程的。

等待唤醒机制

notify/notifyAll和wait方法,在使用这3个方法时,必须处于syn代码块或者syn方法中,否则会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而syn关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。

synchronized (obj) {
       obj.wait();
       obj.notify();
       obj.notifyAll();         
 }
sleep与wait方法的区别

sleep方法会让线程休眠但不释放锁;
wait方法会让线程暂停,释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后方能继续执行,
注意:notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的同步块或同步方法执行结束后才释放锁。

Thread.sleep(2000)和TimeUnit.SECONDS.sleep(2)区别?

前者使用时并没有明确的单位说明,后者明确表达秒的单位,其实后者的内部实现最终还是调用了Thread.sleep(2000);,但是建议使用TimeUnit.SECONDS.sleep(2)的方式。(TimeUnit是个枚举类型)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容