关于后端:Java并发编程重新认识Synchronized

41次阅读

共计 6533 个字符,预计需要花费 17 分钟才能阅读完成。

synchronized

当两个或多个线程试图同时拜访同一资源时,为了确保代码的正确执行,避免并发编程造成的数据不一致性,该当应用 synchronized 关键字对类或者对象加锁。

如何应用

public class SynchronizedTest {public void test(){synchronized (this){    // 执行代码必须要先拿到以后实例对象的锁 锁住的是以后实例对象
            //TODO
        }
    }

    public synchronized void test1(){ // 等价于 synchronized(this)//TODO
    }

    public void staticTest(){synchronized (SynchronizedTest.class){  // 执行代码必须要拿到类对象的锁 锁的是类对象
            //TODO
        }
        
    }
    public synchronized static void staticTest1(){  // 等价于 synchronized(SynchronizedTest.class)//TODO
    }

}

从以上能够看出,synchronized 的应用形式分为两种,别离是同步办法和同步代码块。
(⚠️留神:锁定的不是具体的某些代码,而是某个对象,如代码中的第一二个办法锁的是实例对象;第三四个办法锁的是类对象)。

可重入性

synchronized 不光能够加锁,还有个比拟重要的个性就是 可重入性。什么是可重入性呢?可重入性就是指 一个线程曾经领有某个对象的锁,再次申请依然能够取得该对象的锁

public class Reentry {public synchronized void m1(){System.out.println("m1 start");
        m2();
        System.out.println("m1 end");
    }

    public synchronized void m2(){System.out.println("enter m2");
    }

    public static void main(String[] args) {new Reentry().m1();}
}

//out 
m1 start
enter m2
m1 end

实现原理

咱们对 如何应用 中对代码进行 javap 反解析出对应的汇编指令。(应用 IDEA 插件(jclasslib),可能在 IDE 中间接查看。)如下是 javap 后果:

-------------------------------------
public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_1
         5: monitorexit
         6: goto          14
         9: astore_2
        10: aload_1
        11: monitorexit
        12: aload_2
        13: athrow
        14: return
-------------------------------------
public synchronized void test1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return

从下面能够看出

  • 同步代码块 应用了 2 个 JVM 命令 monitorentermonitorexit来实现;
  • 同步办法应用 flags:ACC_SYNCHRONIZED 来实现。

先来看看 monitorentermonitorexit
依据 JVM 虚拟机标准第 6 章 monitorenter/monitorexit 介绍来看,monitor 能够了解为一个监视器,每个对象与一个监视器关联,每个监视器与一个被锁计数器关联,监视器被锁住时只有领有一个拥有者。
尝试执行 monitorenter 的线程必须遵循以下条件:

  • 如果监视器关联的被锁计数器为 0,则线程持有该监视器,并将计数器加 1;
  • 如果尝试进入的线程曾经领有关联的监视器,那么从新进入监视器,并将计数器加 1;
  • 如果另一个线程曾经领有监视器,则尝试进入的线程将阻塞,直到该监视器的计数器为零为止,而后再次尝试获取所有权。

执行 monitorexit 的线程必须是原领有监视器的所有者,每执行一次计数器减 1,直到计数器为 0 时,退出监视器。
如果尝试进入的线程曾经领有关联的监视器,那么从新进入监视器,并将计数器加 1。这句话就解释了 synchronized 的可重入性。

再来看看 flags:ACC_SYNCHRONIZED
依据 JVM 虚拟机标准第 2 章 Synchronization 介绍来看,synchronized 办法 会有一个 flags:ACC_SYNCHRONIZED 的标识,当办法调用时会查看这个标识是否能获取监视器,调用synchronized 办法时 ACC_SYNCHRONIZED 会设置为 1,执行线程将持有监视器,在执行线程领有监视器的工夫内,没有其余线程能够进入它。
如果在 synchronized 办法调用期间引发了异样并且该 synchronized 办法不解决该异样,则在将该异样从新抛出该办法之前,该办法的监视器将主动退出synchronized

能够看出,两者实质上都会须要获取监视器,如果获取不到就不会被阻塞,直到获取到监视器。那么这里有个留神的点,即 synchronized 的办法如果抛出异样且不解决的话,办法的监视器就会主动退出。这样就会导致其余线程拜访到异样产生时的数据。

锁优化

在 JDK1.6 之前,synchronized的实现间接去调用 monitorentermonitorexit。而这两个操作都须要去找操作系统申请锁,两头会有一次从 用户态 –> 内核态 的过程。(_Java 的线程是映射到操作系统原生线程的_)所以 synchronized 的效率都比拟低。
JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁打消、锁粗化、偏差锁、轻量级锁等技术来缩小锁操作的开销。

锁打消

锁打消是 JIT 编译器在动静编译同步块的时候,借助一种被称为逃逸剖析(Escape Analysis)的技术来判断同步块所应用的锁对象是否只可能被一个线程拜访而没有被公布到其余线程。也就是针对不可能存在竞争的共享数据的锁进行打消。如下代码所示:

public String test2(){StringBuffer sb = new StringBuffer();
    for (int i = 0; i < 99; i++) {sb.append(i);
    }
    return sb.toString();}

咱们都晓得 StringBufferappend 办法都会加锁,但从上述例子中咱们能够看到 StringBuffer 对象是在办法外部,并不会被其余线程拜访到,此时的锁就会被打消。

锁粗化

锁粗化是 JIT 发现如果在一段代码中间断的对同一个对象重复加锁解锁,其实是绝对消耗资源的,这种状况能够适当放宽加锁的范畴,缩小性能耗费。如下代码所示:

for(int i = 0;i < 99; i++){synchronized(this){do();  
} 

// 锁粗化
synchronized(this){for(int i = 0;i < 99; i++){do();
      }   
}
         

自旋锁

自旋锁的呈现是因为每次阻塞或唤醒一个线程都须要操作系统的帮忙,这样的状态转换都挺消耗工夫,起初发现共享数据的锁定状态都比拟短暂,很显著这样都状态切换很不值得。自旋锁的理念就是让想要持有锁的线程期待,做一个循环去获取锁,看持有锁的线程是否会很快的开释锁。如果一旦开释,马上就失去锁。这个过程称之为自旋。但这个自旋也不是有限的,达到肯定次数之后,依然没有获取到锁就会被挂起。
自旋锁也是有利弊的,如果说持有锁的线程很快的开释了锁,那么这种计划的效率是很不错的,因为它防止了线程切换的开销;但如果持有锁的线程没有很快的开释了锁,那么自旋的线程也同时占用了处理器的工夫,这样也是一种节约。如果自旋的线程很多,那对处理器也是一种累赘。所以自旋锁的应用场景就比拟重要了,个别持有锁的执行工夫短,争抢锁的线程数少的场景应用自旋会比拟适合。

自旋锁在 JDK 1.4.2 中引入,默认敞开,然而能够应用 -XX:+UseSpinning 开开启,在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,能够通过参数 -XX:PreBlockSpin 来调整。

自适应自旋锁

JDK1.6 中,默认自旋的次数为 10 次,那如果自旋的线程再多自旋几次就能等到开释锁了呢,古人云:行百里者半九十。JDK1.6 也对此做了一些优化,就是自适应自旋锁。自适应自旋锁示意自旋的次数不是固定的一个值,而是由前一次在同一个锁上的自旋工夫及锁的拥有者的状态来决定。如果线程自旋胜利了,那下一次线程的自旋就会减少,因为虚拟机认为上次的自旋胜利,那么这次也大概率会胜利,故减少线程的次数;那么相应的如果线程经常性自旋失败了,那么下次就会缩小自旋次数甚至省略自旋过程,免得节约处理器资源。

偏差锁

偏差锁的意思就是会偏差于第一个拜访锁的线程,大部分的状况下锁其实并不存在多线程竞争,而且总是由同一个线程屡次取得,这样会让线程取得锁的代价更低。
当一个线程拜访同步块并获取锁时,检测 Mark Word 是否为可偏差状态,即是否为偏差锁 1,锁标识位为 01;若为可偏差状态,则测试线程 ID 是否为以后线程 ID,如果是 执行同步代码块;否则通过 CAS 操作竞争锁,竞争胜利,则将 Mark Word 的线程 ID 替换为以后线程 ID,否则 CAS 竞争锁失败,证实以后存在多线程竞争状况,当达到全局平安点,取得偏差锁的线程被挂起,偏差锁降级为轻量级锁,而后被阻塞在平安点的线程持续往下执行同步代码块。
偏差锁只有遇到其余线程尝试竞争偏差锁时,持有偏差锁的线程才会开释锁,线程不会被动去开释偏差锁。偏差锁的撤销,须要期待全局平安点(在这个工夫点上没有字节码正在执行),它会首先暂停领有偏差锁的线程,判断锁对象是否处于被锁定状态,撤销偏差锁后复原到未锁定(标记位为“01”)或轻量级锁(标记位为“00”)的状态。

  • 开启偏差锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
  • 敞开偏差锁:-XX:-UseBiasedLocking

轻量级锁

当敞开偏差锁性能或者多个线程竞争偏差锁导致偏差锁降级为轻量级锁。轻量级锁并不是取代重量级锁,而是在大多数状况下同步块并不会呈现重大的竞争状况,所以引入轻量级锁能够缩小重量级锁对线程的阻塞带来的开销。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标记位为“01”状态,是否为偏差锁为“0”),虚拟机首先将在以后线程的栈帧中建设一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官网称之为 Displaced Mark Word。而后虚拟机将应用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record 里的 owner 指针指向对象头的 mark word。如果胜利示意竞争到锁,则将锁标记位变成 00(示意此对象处于轻量级锁状态),执行同步操作;如果这个更新操作失败了,虚拟机判断对象的 Mark Word 是否指向以后线程的栈帧,如果是就阐明以后线程曾经领有了这个对象的锁,那就能够间接进入同步块继续执行。否则阐明多个线程竞争锁,轻量级锁就要收缩为重量级锁,锁标记的状态值变为“10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,前面期待锁的线程也要进入阻塞状态。而以后线程便尝试应用自旋来获取锁,在若干个自旋后,如果还没有取得锁,则才被挂起。
轻量级解锁时,会应用原子的 CAS 操作来将 Displaced Mark Word 替换回到对象头,如果胜利,则示意没有竞争产生。如果失败,示意以后锁存在竞争,锁就会收缩成重量级锁。(因为之前在获取锁的时候它拷贝了锁对象头的 markword,在开释锁的时候如果它发现替换回到对象头时失败了,就示意在它持有锁的期间有其余线程来尝试获取锁了,并且该线程对 markword 做了批改。)

锁收缩

synchronized 同步锁执行过程中一共有四种状态:无锁、偏差锁、轻量级锁、重量级锁,他们会随着竞争状况逐步降级,此过程为不可逆。(这种策略是为了进步取得锁和开释锁的效率)。所以 synchronized 锁收缩过程其实就是 无锁 → 偏差锁 → 轻量级锁 → 重量级锁 的一个过程。
锁的状态是跟存储在 java 对象的 markword 无关,markword 是 java 对象数据结构中的一部分,markword 数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中别离为 32bit 和 64bit,它的最初 2bit 是锁状态标记位,用来标记以后对象的状态;对象的所处的状态,决定了 markword 存储的内容,如图所示。

下图是很多 blog 中十分风行对一个锁收缩的图 也比拟具体的还原了整个锁收缩的过程。

  • 当一个锁对象创立后,没有被任何线程拜访过期,是无锁状态。同时它也是可偏差的,就是当第一个线程拜访它时会认为有且仅有第一个线程会拜访它,默认会偏差这个线程。锁标记位为 01。
  • 当一个线程(a)来获取时,会首先查看锁标记位是否为 01,而后查看是否为偏差锁,若不是,则批改为偏差锁(即线程(a)为第一个获取锁),与此同时将 Mark Word 的 线程 id 更改为本人的 线程 id。
  • 后续再有一个线程(b)来获取时,发现是偏差锁,再判断下以后 Mark Word 中的 线程 id 是否为本人的 线程 id,若是,则获取偏差锁,执行同步代码。若不是,就应用 CAS 操作 尝试替换 Mark Word 中的 线程 id,若胜利,则线程(b)示意获取到偏差锁,执行同步代码。(线程 a 应用完之后并不会被动把偏差锁去除,期待其余线程竞争才会开释锁)。
  • 如果失败,则表明以后存在锁竞争,则执行偏差锁的撤销工作,撤销偏差锁的操作须要等到全局平安点(在这个工夫点上没有字节码正在执行)才会执行,而后暂停持有偏差锁的线程,同时查看该线程的状态,如果该线程不处于活动状态或者曾经退出同步代码块,则设置为无锁状态(线程 ID 为空,是否为偏差锁为 0,锁标记位为 01)从新偏差,同时复原该线程。如果线程仍处于活动状态,则会遍历该线程栈帧中的锁记录,查看锁记录的应用状况,如果依然须要持有偏差锁,则撤销偏差锁,降级为轻量级锁。
  • 在降级到轻量级锁之前,持有偏差锁的线程(a)是暂停的,JVM 会在原持有偏差锁的线程(a)的栈帧中创立一个 Lock Record(锁记录),而后拷贝对象头的 Mark Word 的内容到原持有偏差锁的线程(a)的 Lock Record 中,将对象的 Mark Word 更新为指向Lock Record 的指针,这时线程(a)获取轻量级锁,此时 Mark Word 的锁标记为 00。
  • 线程(a)获取到轻量级锁之后,JVM 会唤醒线程(a),线程(a)执行结束之后就会开释轻量级锁。
  • 与此同时,对于其余线程也会在各自的栈帧中建设Lock Record,存储锁对象的 Mark Word 的拷贝,JVM 利用 CAS 操作尝试将锁对象的 Mark Word 更正指向以后线程的 Lock Record,如果胜利,表明竞争到锁,则执行同步代码块,如果失败,那么线程尝试应用自旋的形式来期待持有轻量级锁的线程开释锁。自旋存在肯定次数的限度,如果超出则降级为重量级锁,阻塞所有未获取锁的线程,期待开释锁后唤醒。
  • 轻量级锁的开释,会应用 CAS 操作将之前拷贝过去的 Mark Word 内容替换回对象头中,胜利,则示意没有产生竞争,间接开释。如果失败,表明锁对象存在竞争关系,这时会轻量级锁会降级为重量级锁,而后开释锁,唤醒被挂起的线程,开始新一轮锁竞争,留神这个时候的锁是重量级锁。

    参考链接

    Java 工程师成神之路
    深刻了解多线程(五)—— Java 虚拟机的锁优化技术
    【死磕 Java 并发】—–深入分析 synchronized 的实现原理
    【死磕 Java 并发】—– synchronized 的锁收缩过程
    Java 锁 — 偏差锁、轻量级锁、自旋锁、重量级锁

正文完
 0