Java-多线程的同步实现

Java实现锁的方式主要有2种,一是synchronized,二是并发包java.util.concurrent中Lock接口的实现类ReentrantLock。需要知道的是前者是关键字,JVM原生的亲儿子来着的,后者是封装类,未来JVM改进肯定是先改进synchronized关键字。

 1.volatile(可见性+有序性)

修饰后保证变量的内存可见性,禁止volatile变量与普通变量重排序。volatile有着与锁相同的内存语义,所以可以作为一个 “轻量级”的锁来使用。

说白了就是修饰后的变量每一次变动都会立即刷新到主存,获取的也是主存的最新值,实现多线程同步。

但是,Java中的运算不是原子操作(获取→计算→写入),volatile变量在高并发的情况下也是线程不安全的。高并发情况下一般都配合synchronized使用。

在一些情况下,volatile的同步机制性能要大于锁(synchronized或者并发包java.util.concurrent包里的锁),但是由于JVM对锁实行许多消除和优化,很难量化volatile比synchronized快多少。volatile的读操作性能消耗和普通变量几乎没有什么差别,不过写操作则可能慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行,但总开销还是比锁低。 

volatile在单例模式中禁止指令重排序的效果,双重锁检查。

class Singleton{
    private static Singleton instance;//没有加volatile
    //private static volatile Singleton instance;
    public static Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                if(instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}
/*
instance = new Singleton();分为三个步骤
1. memory=allocate(); //分配内存,相当于C的malloc
2. ctorInstanc(memory);//初始化对象
3. s=memory;//设置s指向刚分配的地址

上述三个步骤可能会被重排序为1-3-2,分配内存并指向内存地址后,但是还没初始化。
此时线程执行到if(instance == null)则也会判定instance不为null,直接返回一个未初始化的instance。
 */

2.synchronized(可见性+有序性+原子性)

用此关键字对代码上锁,锁住的东西是一个对象,叫对象锁。synchronized的使用方式有3种。被修饰的方法不能重写。

(1)修饰代码块,被修饰的代码叫做“临界区”,它规定,临界区的代码同一时刻只能由同一个线程执行。

    // 关键字在代码块上,锁为括号里面的对象
    public void blockLock() {
        Object o = new Object();
        synchronized (o) {
            // code
        }
    }

(2)修饰普通方法,锁为当前对象/实例。

    //关键字在实例方法上,锁为当前实例
    public synchronized void test1() {
        //code
    }
    // 关键字在代码块上,锁为括号里面的对象,二者等价
    public void test2() {
        synchronized (this) {
            //code
        }
    }

(3)修饰静态方法,锁为当前的Class对象。

    //关键字在静态方法上,锁为当前Class对象
    public  static synchronized void test3() {
        //code
    }
    //二者等价
    public void test4() {
         synchronized (this.getClass()) {
         // code
         }
    }

3.一些锁的概念

(1)公平锁

公平锁指多个线程等待一个锁时,按照申请锁的先后顺序来依次获得锁;

非公平锁就不保证这一点,释放锁后所有申请锁的线程都有机会获得锁,例如synchronized。

(2)自旋锁与自适应自旋

互斥同步对性能最大的影响就是阻塞的实现,挂起线程和恢复线程都需要开销。

自旋锁就是要减少这类情况带来的系统开销,例如线程A在申请锁X的时候,发现锁X被线程B占用,但是线程A不挂起,继续申请,这个申请操作叫做自旋,需要耗费处理器资源。如果稍微等一会就拿到锁X,那就不用花费挂起线程的开销,赚大了;如果要等很久才能拿到锁X,这个等待时间耗费的处理器资源可能更大,那就亏死了。可以通过判断自旋次数 来停止继续自旋,用传统的方式挂起线程,避免耗费太多资源。

自适应自旋(JDK1.6引入)就是 针对有些锁可能要用很久 使得 申请的线程自旋次数很多,有的锁可能一下子就好 使得  申请的线程自旋次数很少,自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。同一个锁对象上,如果上一个申请锁的线程自旋很少次数就获得锁,那么本次的自旋次数可以持续相对更长时间;如果上一个申请锁的线程自旋好多好多次才拿到锁,那可能不自旋了,免得浪费时间,直接挂起。自适应自旋使得JVM对程序锁的状况预测越来越准确,减少开销。

(3)锁消除

指JVM在运行时,对一些代码要求同步,但是检测到不可能就不存在共享数据竞争,那么就会将那个运用于同步的锁消除。锁消除的判定依据来源于逃逸分析的数据支持,如果判断一段代码,堆上的所有数据都不会逃逸出去从而被其他线程访问到,就可以把它当作栈上的数据来对待,认为它们线程私有,不用加锁。

疑点:加不加锁程序员自己不知道吗?还要靠JVM?

解答:有些同步措施不是程序员自己加的。例如有一些代码看着没锁,其实有锁,但又不需要锁,就锁消除。

(4)锁粗化

原则上我们加锁的代码块都设置的尽量小,使得需要同步的操作数量尽可能变小,如果存在锁竞争,等待锁的线程也能快点拿到锁。但如果一系列操作都是对同一个锁进行加锁解锁,这样频繁互斥同步会导致不必要的性能损耗。锁粗化针对这种情况,把加锁的同步范围扩展(粗化)到整个操作序列的外部,即原本加锁许多次,现在只加锁一次。

(5)轻量级锁

JDK1.6加入,“轻量级”是相对 使用操作系统互斥量来实现的传统锁而言,传统锁叫“重量级”锁。他的本意是 在没有多线程竞争资源的时候,不用重量级锁,减少开销。

  • 了解一下对象头:它是对象附带的信息,与存储的数据无关。对象头 = MarkWord+类型指针。前者用于存储自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,如果是数组对象,还有一块用于记录数组长度的数据,所以大小不定。后者是指向它的类元数据的指针,通过这个指针确定这个对象是哪个类的实例。
  • 了解一下CAS操作:全称是Compare And Swap,比较并替换的意思。有3个基本操作数,内存地址V,旧值A,新值B。假设有个内存地址V,存着值为10的变量。现在线程1要对这个变量加1,对于线程1来说,A=10,B=11。线程1要更新前,线程2抢先一步,把内存地址的变量值率先改为11。此时线程1提交更新,用自己的A去和地址V的实际值比较,发现A不等于实际值,提交失败。那就再来一次,重新获取地址V的实际值,A=11,计算得B=12,这个重来的过程叫自旋。这一次没有其他线程来捣乱,线程1提交更新,比较自己的A和地址V的实际值,发现相等,就把地址V的实际值替换为B,也即是12。CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新。多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余失败的不会被挂起,可以继续尝试,也可以放弃尝试。
  • 了解一下ABA问题:一个值原来是A,变成了B,又变回了A。这个时候使用CAS是检查不出变化的,但实际上却被更新了两次。解决措施是在变量前面追加版本号和时间戳

进入同步块之前的 加轻量级锁的过程,例如线程A要获取锁Obejct。首先,通过锁的对象头的信息看锁Obejct有没有被其他线程锁定,如果没有,就在自己的栈帧中创建一个名为锁记录LockRecord的空间,用于存储锁对象头目前的MarkWord的拷贝,官方把这份拷贝加了一个Displaced前缀,即DisplacedMarkWord,这时候线程A的栈帧与锁Object的对象头如图所示:

然后,JVM通过CAS操作 尝试将对象锁Object的MarkWord更新为指向 栈帧锁记录的指针。

如果这个操作成功了,那么线程A就获得了锁Object,并改一下Obejct的MarkWord的锁标志位,表示处于轻量级锁定状态。

如果CAS操作失败了,JVM就检查一下对象的MarkWord是否指向线程A的栈帧,如果是则说明线程A拥有了锁Object,可以继续执行同步块代码了。

否则说明锁Object被其他线程抢了,存在两个以上的线程抢一个锁时,这个锁就被升级为重量级锁(改锁标志位),则线程A和后面需要用这个锁的线程都要进入阻塞状态。

轻量级锁的解锁过程也是通过CAS操作来实现的,如果锁Object的Mark Word仍然指向着线程A的锁记录,那就用CAS操作把对象当前的MarkWord和线程中复制的DisplacedMarkWord替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁的意义就是优化 没有竞争的锁 却仍然互斥同步带来的开销。

(6)偏向锁

JDK1.6加入,偏向锁的“偏”是偏心的意思,意味着这个锁会偏向第一个获得它的线程。举例说明,如果锁X第一次被线程A使用了,那么锁X在对象头设置为“偏向模式”,偏向线程A,下一次线程A要使用锁X时,不需要进行同步操作(Locking,Unlocking,对MarkWord的Update等);如果有线程B要获取锁X,那么锁X会“撤销偏向”恢复到 未锁定或者轻量级锁定状态,后续又是轻量级加锁过程。

(7)重量级锁

重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。

(8)乐观锁和悲观锁

悲观锁认为每次访问共享资源会有冲突,所以对临界区上锁;适用于"写多读少"的环境,避免频繁失败和重试影响性能。

乐观锁认为每次访问共享资源都不会有冲突,不上锁,即无锁;适用于"读多写少"的环境,避免频繁加锁影响性能。

(9)锁的升级流程

每一个线程在准备获取共享资源时

第一步,检查MarkWord里面是不是放的自己 的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。

第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程 将Markword的内容置为空。

第三步,两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作,把锁对象的MarKword的内容修改为自己新建的记录 空间的地址的方式竞争MarkWord。

第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 。

第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败。

第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

 


 参考&引用

《深入浅出Java多线程》

《深入理解Java虚拟机第二版》

原文地址:https://www.cnblogs.com/shoulinniao/p/12634424.html