java同步机制

一、前言

很多程序员在学习java语言的第一步就已经对“并发”、“同步机制”有一定的认识和理解,但是并发这个新事物,是建立在硬件基础之上。在计算机设计早期,为了更多的计算性能的需求,将单处理器系统发展成为多处理器系统,而在现在,更是将多个计算核放在单个芯片上,无论是多个计算核还在多个CPU芯片上,还是在单个CPU芯片上,都为多核处理器系统。随着前人的苦苦探索,在1996年,java终于诞生了,由于站在巨人的肩膀上,在JDK1.0版本就提出了java语言的内存模型,并有了多线程模式,这个创新,太伟大了。但是仍有不足,在2004年的9月,JDK1.5发布,并正式更名为5.0,这个版本发布了大规范,即java内存模型和线程规范,并引入了java.util.concurrent包。Doug Lea(下图)这位带来里程碑的大人物,大家一定不陌生。java继续进步,在JDK7中进一步完善了并发控制功能,并引入了fork-join框架。不得不感慨,科技的飞速发展。

 

二、正文

先来感受一下并发在我们日常开发工作中的地位。我们常见的问题有“在一个list中有过亿条的Integer类型的值,如何更快的计算这些值得总和”、“count++多线程下的问题”、还有“主线程一直没有感知到flag的变化,进入了死循环”等等,这些都从不同的角度来诠释着Java并发的同步机制。

1:为什么需要同步

先解释一下并发(concurrency)的概念,多个程序或者一个程序的多个线程交替运行在一个CPU上,微观上看,一时刻这个CPU上只有一个进程或线程在运行,宏观上看,一个时间窗口内这个CPU上多个进程或者线程同时在运行,这叫做并发。

我们知道,线程机制允许同时进行多个活动,但是并发的情况下,共享同一个资源由于保证不了原子性、有序性等特征,在运行过程中很可能结果不准确,以我们熟知的count++和单例模式为例。

例子1:多线程下count++

import java.util.ArrayList;
import java.util.List;
 
public class ThreadCount extends Thread {
    public static int count = 0;
    public static void inc() {
        //这里延迟1毫秒,使得结果明显
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
        }
        count++;
    }
    public static void main(String[] args) throws InterruptedException {
        //同时启动1000个线程,去进行i++计算,看看实际结果
            outPutCount();
        /**
         * 输出的结果为
         * 运行结果:Counter.count=990
         * 运行结果:Counter.count=990
         * 运行结果:Counter.count=983
         * 运行结果:Counter.count=987
         * 运行结果:Counter.count=980
         * 运行结果:Counter.count=983
         * 运行结果:Counter.count=985
         */
    }
    public static void outPutCount() throws InterruptedException {
        List<Thread> threadList = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    ThreadCount.inc();
                }
            });
            thread.start();
            threadList.add(thread);
        }
        for (int i = 0; i < 1000; i++) {
            threadList.get(i).join();
        }
        //这里每次运行的值都有可能不同,可能为1000
        System.out.println("运行结果:Counter.count=" + ThreadCount.count);
    }
}

例子二:单例模式

public class Singleton {
    private static Singleton instance = null;
 
    private Singleton() {}
 
    public static Singleton getInstance() {
    //多线程环境下,检测instance == null和修改 instance = new Singleton()不是原子操作,存在问题
        if (instance == null) {
            instance = new Singleton(); 
        }
        return instance;    
}
}

从上图的例子中我们会发现一个问题,有时候我们期望和运行结果大相径庭。也就是共享变量在多线程环境下会有问题。而解决方案就是需要同步机制。假如没有同步机制,一线程的变化就不能被其他线程看到,也就是同步机制成功阻止了一个线程看到对象处于不一致的状态之中,还保证了进入同步方法或者同步代码块的每一个线程,都看到由同一个锁保护之前所有的修改效果。

 

2、有哪些同步机制:

Volatile

它是最轻量级的同步方式,也是比较难理解的同步方式,一般作用在标记变量、计数器变量、并发容器的变量上,一般和CAS一起使用。

下面我们用读写操作都是原子性的boolean类型的变量作为共享变量,来看下面的例子,根据共享变量来让backgroundThread线程终止。

import java.util.concurrent.TimeUnit;
​
public class StopThread {
    private static boolean stopRequested;
    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread =new Thread(()->{
            int i=0;
            while (!stopRequested){
                i++;
            }
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested=true;
    }
}

随着主线程将stopRequested设置为true,按理说线程backgroundThread会终止循环,实际上,在我电脑上,是永远不会终止的。

具体的原因如下:

没有同步机制,虚拟机会将下面的代码:

while (!stopRequested) 
     i++;

转变成这样:

 boolean hoistedStopRequested=stopRequested;
  while (!hoistedStopRequested){
        i++;
 }

这种优化称为提升(hoisting),也就是HotSpot VM Server Compiler对该方法的优化,该编译器优化都在JIT编译器中做的,而不是在java源码到字节码的编译起。

由于stopRequested是一个静态变量,编译器本来是需要对它做保守处理的,但编译器发现这个方法是一个叶子方法,并不调用任何方法,也就是只在run()方法中运行,在同一线程内就不可能有其他代码能观测到stopRequested的值变化。因此,编译器就大胆的走上了冒险之路,将其读取操作提升(hoist)到循环之外。所以导致了线程backgroundThread一直在循环。

这里要重点强调一下,这种情况下该循环问题是由于编译器优化,结果是一个活性失败(liveness failure),这个程序并没有提升。解决方案也有很多。

解释一下活性失败的意思,是在多线性并发时,如果A线程修改了共享变量,此时B线程感知不到此共享变量的变化,叫做活性失败。

那么如何解决呢?解决方案很多,比如增加几个无关的变量等,让编译器不再优化,都可以停止。

 public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread =new Thread(()->{
            int i=0;
            Integer integer=new Integer(1);
            while (!stopRequested){
                i++;
            }
          
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested=true;
    }

比如下面,我增加了Integer integer=new Integer(1);可以终止循环,或者是System.out.println(i);也可以终止循环。

当然了,上面的循环也可能会终止,可能这个run()方法还没有来得及被编译,stopRequested变量就已经被main线程设置为true了,因而run()方法里面的代码还在解释器中运行,并没有得到任何优化,然后就直接终止了。

当然,本段落的主角是volatile,使用它也可以轻松的解决这个问题。下面列举一下volatile的功能:

  • 禁止缓存

    在java中,有以下天然的happens-before关系:

    1、程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作。

    2、监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

    3、volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

    4、传递性:如果A happens-before B,并且 B happens-before C ,那么A happens-before C。

    5、start规则:如果线程A执行操作ThreadB.start()启动线程B,那么A线程的T hreadB.start()操作happens-before于线程B中的任意操作。

    6、join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A的从ThreadB.join()操作成功返回。

    其中,volatile就是第三条,volatile变量的访问控制符会加一个ACC_VOLATILE,也就是当一个线程对volatile修饰的变量进行写操作之后,会加入一个写屏障,会立即把该线程对应的本地内存中的共享变量的值刷新到主内存;当一个线程对volatile修饰的变量进行读操作的时候,会在读之前加入一个读屏障,会把该线程对应的本地内存设置为无效,从主内存中读取共享变量的值。通过迫使编译器放弃对它的任何冒进的优化,而总是会从内存重新访问其值来达到目的。

  • 不做重新排序

    volatile严格限制编译器和处理器对volatile变量与普通变量的重排序的。通过编译器在生成字节码时,选择内存屏障插入策略,通过这个来保证volatile的内存语义。具体流程如下:

    在每一个volatile写操作前插入一个StoreStore屏障

    在每一个volatile写操作后插入一个StoreLoad屏障

    在每一个volatile读操作后插入一个LoadLoad屏障

    咋每一个volatile读操作后再插入一个LoadStore屏障

    具体解释:

    StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;

    StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序

    LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序

    LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序

    下面我们来看如下代码块:

        int a = 0;
        int b = 0;
        int c = 0;
        int d = 0;
        volatile int v = 0;
    ​
        a =10;    // 语句1
        b =20;    // 语句2
        v =30;    // 语句3
        c =40;    // 语句4
        d =50;    // 语句5
     

    由于v变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会将语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

    并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的。

    我们再来看下面的代码块

    // 初始
    context  = null;
    inited = false;
    // 线程A
    while (!inited )  {    //语句3
         Thread.sleep(1000);
    }
    useContext(context);   //语句4
    ​
    // 线程B
    context=loadContext(); //语句1
    inited = true;         //语句2

    当线程A和线程B同时执行的时候,会发生什么?

    由于语句1和语句2没有强关系,可能先执行语句2,再执行语句1,那么线程A就会绕过语句3,直接执行语句4,而此时此刻,context还没有执行初始化操作,会报错。

    如何解决,当然是文章中带着主角光环的volatile了。那么,该哪个变量加呢?

    当然,都加关键字volatile可以解决这个问题。那么在一个变量上加关键字可以解决这个问题吗?事实上,在inited变量上加volatile关键字就可以了,加了关键字之后,语句1和语句2的顺序得到了保证,同时在while (!inited )读变量inited的时候,使得CPU中所有的invalidate缓存(在其他CPU有更新)都失效,所以inited和context的变量都失效,都需要从主内存中读取。

    该例子使用了volatile的禁止缓冲语义和不做重排序的语义。

    CAS

    CAS也是同步机制的一大利器,它的全称是:比较并交换(compare and swap),多和自旋一起使用。在CAS中,有这样的三个值:

    • V:要更新的变量(val)

    • E:预期值(expected)

    • N: 新值(new)

    比较并交换的过程如下:

    判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其他线程更新了V,则当前线程放弃更新,什么都不做。

    它需要原子的机器指令支持,如cmpxchg指令,封装在sun.misc.Unsafe类中(代码如下)。

    public final native boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);                                                                                                   public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x); 
    public final native boolean compareAndSwapLong(Object o, long offset,long expected,long x);

    CAS是单条指令,不涉及线程的阻塞和唤醒。

    CAS是原子操作类、synchronized、lock、semaphore等上层同步方法实现的基石。

    比如我们经典例子count++,就可以使用AtomicInteger来修复。其中AtomicInteger的代码如下:

    public class AotmicInteger {
        private volatile int value;
        public final int incrementAndGet() {
            return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
        }
    }
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    ​
        return var5;
    }

    通过while循环来不断尝试CAS更新,如果更新失败,就继续重试。CAS的原子操作也带来了如下的问题:

    1、ABA问题:一个值原来是A,变成了B,之后又变回了A,这个时候使用CAS是检查不出变化的,可以通过追加版本号或者时间戳来解决,也可以使用AtomicStampedReference类来解决这个问题。

    2、循环时间长开销大:CAS与自旋结合,如果自旋CAS长时间不成功,会占用大量CPU资源。

    Synchronized

    语言内建关键字,作用在对象上,通常来给一段代码或者一个方法上锁,实现代码段的互斥执行,可重入。是最易用的同步方式。与之对应的等待/通知机制(wait/notify/notifyAll)也内建在Object中。

    它在JDK 1.6的时候,对性能有优化,有一个锁升级的过程,从无锁->偏向锁->轻量级锁->到重量级锁,具体不在陈述。可以参考下面的资料

    [《死磕Synchronized底层实现》]  https://github.com/farmerjohngit/myblog/issues/12 

    其中单例模式的双重检查,就是用的synchronized,我们来看下面的代码

    public class Singleton {
        private static volatile Singleton instance = null; //语句1 
        private Singleton() {}
        public static Singleton getInstance() {
            if (instance == null) {
                synchronized(Singleton.class) { //语句2             
       if (instance == null) {
                        instance = new Singleton(); //语句3
                    }
                }
            }
            return instance;
        }
    }
    ​

    现在我们来思考一下,语句1能否去掉能否去掉volatile?语句2能否去掉

    synchronized呢?

    首先,假如没有synchronized,在并发的情况下,就会出现instance = new Singleton()的多次执行,也就是破坏单例模式了。

    那么去掉volatile可以吗?显然是不可以的。那么在双重检查锁模式中为什么需要使用volatile关键字?

    这是因为 singleton = new Singleton() ,它并非是一个原子操作,在 JVM 中这条述语句至少做了以下这 3 件事:

    第一步:给 singleton 分配内存空间;

    第二步:开始调用 Singleton 的构造函数等,来初始化 singleton;

    第三步:将 singleton 对象指向分配的内存空间(执行完这步 singleton 就不是 null 了)。

    这里需要留意一下 1-2-3 的顺序,因为存在指令重排序的优化,也就是说第 2 步和第 3 步的顺序是不能保证的,最终的执行顺序,可能是 1-2-3,也有可能是 1-3-2。如果是 1-3-2,那么在第 3 步执行完以后,singleton 就不是 null 了,可是这时第 2 步并没有执行,singleton 对象未完成初始化,它的属性的值可能不是我们所预期的值。假设此时线程 2 进入 getInstance 方法,由于 singleton 已经不是 null 了,所以会通过第一重检查并直接返回,但其实这时的 singleton 并没有完成初始化,所以使用这个实例的时候会报错。

    使用了 volatile 之后,相当于是表明了该字段的更新可能是在其他线程中发生的,因此应确保在读取另一个线程写入的值时,可以顺利执行接下来所需的操作。从而避免了上述由于重排序所导致的读取到不完整对象的问题的发生。

    Park/unPark

    阻塞/唤醒线程,它需要操作系统API的支持,封装在sun.misc.Unsafe类中。park/unpark是LockSupport、Lock、 Semaphore等上层同步方法实现的基石。具体代码定义如下:

    public native void park(boolean var1, long var2);
    public native void unpark(Object var1);

    下面列举一下基于Park/unPark的同步方法

    同步方法说明
    ReentrantLock可重入排他锁
    ReentrantReadWriteLock可重入读写锁
    Semaphore信号量,共享锁,实现并发控制
    CountDownLatch递减门闩,用于多个线程之间的进度协同
    CyclicBarrier同步屏障,用于多个线程之间的进度协同
    Exchanger两个线程交换数据,用于遗传算法,或者Double Check
    Phaser阶段同步器,类似于CyclicBarrier,但能支持多阶段进度协同

以上的同步方法,在平时的工作中应该会经常用到,比如Semaphore信号量,在控制接口的最大并发数的时候就可以用到,闭锁CountDownLatch确保某个服务在其依赖的所有其他服务都已经启动之后才启动等。

三、后记

开发工作中,经常使用的线程安全的容器,也都使用了文章列举的几种同步机制,比如早先的Vector、Hashtable、synchronizedList、synchronizedMap,都是使用synchronized来同步,锁的粒度较粗,性能比较较差,现在已经不推荐使用了。Java 5引入的java.util.concurrent包,包括ConcurrentLinkedQueue、ConcurrentHashMap、ArrayBlockingQueue、Semaphore等。使用无锁或者锁的粒度较细,性能较好。

 

参考资料:

《Effective java》

《java并发编程的艺术》

《深入理解JAVA虚拟机》

[死磕Synchronized底层实现]  https://github.com/farmerjohngit/myblog/issues/12 

[RednaxelaFX文章]  https://www.zhihu.com/question/39458585/answer/81521474 

 

 

 

 

 

 

 

 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值