Java并发编程(一)监视器锁

/ 默认分类 / 0 条评论 / 1212浏览

Java并发编程(一)监视器锁

1.前言

谈到并发编程就需要说一下资源共享,也就是一个或多个线程可以共同持有同一个数据资源,那么当这些线程同时读写同一数据的时候就会出现 脏数据或者其他不可预测的结果,导致读取或写入的数据不符合实际情况。在java中,为了解决这样的问题,就出现了并发编程包,也就是我们 常说的JUC(java.util.concurrent),下面我将从synchronized同步锁开始介绍一直到juc中自带的并发基础框架。总结这些博客的时候我 也借助了一些书籍和文档,有不正确或者不准确的地方欢迎大家一起探讨。

2.并发问题引入

很多并发编程的文章或者书籍都是从一个多线程的计数器程序引入这个问题的,当然这个我在下面也有说道,但是这里我想 从操作系统的角度来引入并发问题。首先,进程之间不存在数据变量的共享,他们都具有自己独立的地址空间,但是进程之间 共享一套文件系统,所以对于对文件的读写操作,不同的进程并发操作也是会存在并发问题,可能会导致文件最终写入的数据 和程序预期效果不一致,但是对于线程,一个进程中可以有多个线程,不同的线程共享当前进程地址空间中的堆内存,但是不同的 线程会有自己独立的栈空间,所以线程之间会存在共享变量读写的问题,这也就是java中主要的并发问题。

单线程进程和多线程进程的地址空间的对比如下(摘自操作系统导论一书)
单线程进程和多线程进程的地址空间

2. 简答并发问题引入

下面我写了一个简单的程序,两个线程同时累加一个计数器参数(共享变量)

ps:关于共享堆内存区域这些知识点,可以去了解JVM相关知识

public class Test {

    static int count;

    public static void main(String[] args) {
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            for (int i = 0; i < 10; i++) {
                count++;
                System.out.println(Thread.currentThread().getName()+":"+count);
            }
        },"thread01").start();

        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            for (int i = 0; i < 10; i++) {
                count++;
                System.out.println(Thread.currentThread().getName()+":"+count);
            }
        },"thread02").start();
    }

}

上面的程序,运行两次得到了两种不同的结果

thread02:1
thread02:2
thread02:3
thread01:1
thread01:5
thread01:6
thread01:7
thread02:4
thread01:8
thread01:10
thread01:11
thread01:12
thread01:13
thread01:14
thread02:9
thread02:15
thread02:16
thread02:17
thread02:18
thread02:19
thread02:2
thread02:3
thread02:4
thread02:5
thread02:6
thread02:7
thread02:8
thread01:2
thread02:9
thread01:10
thread01:12
thread01:13
thread01:14
thread01:15
thread01:16
thread01:17
thread01:18
thread01:19
thread02:11
thread02:20

可以发现,第二次执行,最终并没有按照程序的效果来执行,最终的count没有累加到20,而是19,也就是少了一个加1操作。原因也很简单,当两个线程同时读取count这个变量时,同时修改了数据,但是本次的两个线程修改,只有一个修改最终被同步为数据的变化,过程如下

可以看到,这样执行后,等于又一次加1的操作被覆盖了,所以最终执行的结果就是少了一次累加。所以这就需要使用简单的同步并发控制,对修改这个操作加锁,同一时间只允许一个线程对count进行操作(先不考虑内存可见性问题)
java中最常用的同步控制就是关键词synchronized.下面我们来详细介绍,java中的并发控制实现。

3. 内存可见性引入

首先需要了解下JMM,也就是java内存模型,其中规定的模型如下:

在java程序执行时,内存模型是,首先线程将变量从主内存取出到自己的工作内存,然后在工作内存进行相关操作,操作完成 后将最新的数据刷新更新到主内存。

对于上面的内存模型,下面来看下我们上面计数器程序的执行过程: (1).线程1先读取变量count,发现自己的工作空间缓存(lv1 cache)没有,然后读取主内存的缓存(lv2 cache)也没有,所以最后直接 读取主内存中的数据,读取到count=0,然后count=0写入lv2 cache,count=0又写入到线程的工作空间缓存(lv1 cache) 然后将count++,此时count=1,执行完后,count=1更新到lv1 cache和lv2 cache,之后又刷新了主内存中的数据count=1;

(2).线程2开始读取count,lv1 cache没有,但是lv2cache有,读取到count=1,然后写入自己的线程工作空间缓存(lv1 cache),修改count++, 此时count=2,并且线程2写入count=2到自己的lv1 cache,然后又将count=2写入lv2 cache,并且将主内存count刷新为2

(3).此时如果线程1再次执行加1操作,那么首先读取count值,自己的工作空间的lv1 cache命中,所以count=1,然后累加之后得到count=2,但是此时主内存中的数据 应该是count=2,加1之后正确的应该是count=3,所以这样的结果就是错误的,造成这样的问题的原因就是java中不同的线程之间的内存不可见性。

所以按照上面的java内存模型,对于上面的计数器程序,如果两个线程同时启动,同时第一次从主内存获取数据count到各自的工作空间,那么最终执行完毕后,线程1和线程2 打印出来的最终数值应该都是10才对(不同线程是修改自己内存空间的数据),那么为什么真实的结果却是正常的呢(先不考虑并发修改的情况)?我们上面的程序也没有进行内存可见性的设置呀(volatile)?

下面我来解释一下,先来看下面几个程序:

public class Test {

    static int count;

    public static void main(String[] args) {

        //线程1会先睡眠1s,然后修改count值为10
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            count = 10;
        }, "thread01").start();


        //如果count是初始时候的值,也就是0,那么线程2就会一直循环执行,不会终止  
        new Thread(() -> {
            while (count == 0) {
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }, "thread02").start();

    }
}

上面的程序应该是下面两种执行结果:

从上面的分析可以看出,这个程序应该是第二种情况,这样才符合java中的内存不可见性的特征,但是程序真实的执行结果其实是第一种情况,那么是不是说java内存模型是不对的,其实恰恰相反,上面的程序 中,线程1和线程2都是会sleep睡眠,也就是线程会变为阻塞状态,然后睡眠结束后,变为可运行状态,当cpu重新分配了时间片到线程2,那么就进入运行状态,此时线程2会重新从主内存获取数据,恢复线程 上下文开始执行,所以这也就解释了上述程序为什么没有使用volatile或synchrinized也可以实现内存可见性。

另外,如果你在写类似的测试程序的时候,使用了print打印,那么也会出现类似的问题,比如最上面的计数器程序,两个线程程序的睡眠是在最开始,按照java中线程之间的内存可见性特征,执行的记过应该是每个线程最终累加 后打印的count值分别都是10才对,但是很明显执行的结果是正确的(不考虑并发修改的情况),那么这又是为什么呢?其实System.out.println();的源码是这样的:

    public void println(char x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }

很明显了,其实打印的时候使用的是synchronized同步代码块,后面我会介绍,synchronized也可以实现java中线程的内存可见性

4.java中内存可见性的实现方式

首先来介绍第一种实现内存可见性的关键词,synchronized,先来介绍一下synchronized的语义:

另外,synchronized是java中的一种同步锁,线程执行synchronized同步块中的代码会先获取同步锁,获取到以后就可以执行,在释放锁之前其他线程就无法执行代码块中的程序了,其实也就是 一种排它锁(和数据库中的悲观锁-排它锁(写锁)一样),所以synchronized不仅可以实现内存可见性还能实现操作原子性。

虽然使用同步锁可以实现内存可见性,但是加锁会导致线程阻塞,重新执行,这也就涉及到操作系统线程上下文的切换(用户态和内核态之间的切换),这样会比较耗时。在java中还有另一种实现内存可见性的 方法---使用volatile关键词

volatile实现可见性的原理就是,线程在执行完逻辑后,会直接将volatile修饰的变量刷新到主内存,不会缓存在自己的工作空间,所以下一次再次读取不会读取到自己工作空间的缓存值,而是直接读取 主内存的最新值,其他线程读取使用这个volatile变量也是这样的操作,所以就实现了内存可见性

可以看出来,volatile虽然可以实现内存可见性,但是因为没有加锁的同步操作,所以volatile不能实现原子性,因此,当写入的数据依赖当前读取的值时,此时只使用volatile就无法保证数据写入正确 了,因为这个过程是: 获取-计算-写入,这整个过程不是原子性的,需要通过加锁来保证原子性。

5.原子性和原子性操作

先来看下面一个简单的程序:


public class Test1 {

    int a;

    public void init(){
        a++;
    }
}

上面这段代码的init方法的操作就是让变量a值加1,下面是使用javap生成的jvm汇编指令

 public void method01();
    Code:
       0: aload_0
       1: dup
       2: getfield//先获取值a      #2                  // Field a:I
       5: iconst_1 //加1操作
       6: iadd//
       7: putfield//重新写入赋值      #2                  // Field a:I
      10: return

可以看到,a++这步其实是三步操作,这也就是读-改-写的操作,这就是典型的非原子性操作。

什么是原子性?
按照百度百科的解释,原子性是2018年公布的计算机技术名词,指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。

也就是说,在一系列的操作中,这些操作要不全部执行成功,要不全部执行失败,不存在一些执行成功了,一些执行失败的情况。

什么是原子性操作?
原子性操作就是这个操作执行的结果具有原子性,比如一个很简单的计数器程序

public class Test {

    static int count;

    public static void main(String[] args) {
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            for (int i = 0; i < 10; i++) {
                count++;
                System.out.println(Thread.currentThread().getName()+":"+count);
            }
        },"thread01").start();

        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            for (int i = 0; i < 10; i++) {
                count++;
                System.out.println(Thread.currentThread().getName()+":"+count);
            }
        },"thread02").start();
    }

}

首先这里因为加入了打印,所以具有内存可见性,但是因为没有加锁,所以对于:获取-计算-写入这样一个系列的操作,就不是一个原子性的操作,因为执行过程中 可能出现线程1和线程2同时获取了数据,然后同时修改了数据count为1,最后这两次累加都结束了写入主内存的数据是count=1,但实际上正确的结果应该是count=2, 所以这样的两个操作执行后的到的结果是不正确的,所以这个操作不具有原子性,可以为 获取-计算-写入这个过程加上同步锁,这样就可以保证数据最终累加后 得到的结果是正确的,也就保证了操作的原子性. 所以对于上面的累加器的程序,可以理解为原子性操作就是一个操作是不可分割的整体,java中的原子性操作 可以认为是一个或某几个操作只能在一个线程执行完之后,另一个线程才能开始执行该操作,也就是说这些操作是不可分割的,线程不能在这些操作上交替执行。

另外,对于事务的原子性,进程会用在数据库中,在一个事务中,要不所有的指令都执行成功,要不全部都执行失败,不能出现有的执行成功有的执行失败。最典型的比如 转账的例子,在一个事务中,先A-20,之后执行B+20,然后提交事务,这个过程必须全部成功或失败,否则就会出现数据不一致的问题。

6. 内存可见性

总结一下: 使用synchronized可以保证操作的原子性,并且也可以保证内存的可见性,使用volatile只能保证内存可见性,但是synchronized会导致 程序的运行效率降低,因为这是一个排它锁,其他没有活的到锁的线程需要阻塞,这样就难免出现上下文切换导致耗时,也就是说比如下面的程序:

public class Test {

    int a;

    public synchronized int getA() {
        return a;
    }

    public synchronized void setA(int a) {
        this.a = a;
    }
}

get方法和set方法都加上了同步锁,但是get方法是只读的,为什么还要加同步锁呢?首先这是为了实现内存可见性,在获取的时候直接会从主内存中获取最新数据,其次 这样也可以保证:获取-计算-设置这样的步骤具有操作原子性(synchronized是属于可重入锁)

volatile只能保证内存可见性,那么有什么办法可以保证,get这样的只读方法不需要加同步锁也可以保证读-改-写这样的操作具有原子性呢?

7.java中的CAS操作支持

CAS意思是compare and swap ,也就是比较并替换,java中的Unsafe类提供了cas操作,关于Unsafe这个操作我在之前介绍ConcurrentHashMap时有提到过,可以参考我的那篇文章,这里简答 说明下:

boolean compareAndSwapLong(Object obj,long valueOffset,long expect, long update)
在对象obj中,如果obj所在内存地址偏移量为valueOffset的位置的变量值和expect值相同,那么就使用update替换该偏移量位置的数据返回true,否则就返回false

Unsafe类底层调用c++程序,为java程序提供了间接操作内存,控制线程状态,CAS操作等
关于Unsafe类的api介绍可以参考这篇文章

因为Unsafe类可以直接对内存进行操作,在java这样的自动管理内存的语言来说,暴露这样的操作是不安全的,所以Unsafe的构造方法是私有的,并且其中的方法:

    public static Unsafe getUnsafe() {
        Class var0 = Reflection.getCallerClass();
        //如果当前是用户程序调用,那么就抛出安全异常(Bootstrap类加载器null)
        if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
            throw new SecurityException("Unsafe");
        } else {
            return theUnsafe;
        }
    }

所以无法通过常规的方法获取Unsafe对象的实例,但是如果真的需要获取,可以使用反射:

public class Test {

    int a;

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        Test test = new Test();
        Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
        theUnsafe.setAccessible(true);
        Unsafe unsafe = (Unsafe) theUnsafe.get(null);
        long offset = unsafe.objectFieldOffset(Test.class.getDeclaredField("a"));
        boolean b = unsafe.compareAndSwapInt(test, offset, 0, 100);
        System.out.println(b);
        System.out.println(test.a);
    }
}

Unsafe中的cas操作调用的底层c++程序如下:

jbyte Atomic::cmpxchg(jbyte exchange_value, volatile jbyte*dest, jbyte compare_value) {
		 assert (sizeof(jbyte) == 1,"assumption.");
		 uintptr_t dest_addr = (uintptr_t) dest;
		 uintptr_t offset = dest_addr % sizeof(jint);
		 volatile jint*dest_int = ( volatile jint*)(dest_addr - offset);
		 jint cur = *dest_int;
		 jbyte * cur_as_bytes = (jbyte *) ( & cur);
		 jint new_val = cur;
		 jbyte * new_val_as_bytes = (jbyte *) ( & new_val);
		 new_val_as_bytes[offset] = exchange_value;
		 // 比较当前值与期望值,如果相同则更新,不同则直接返回当前值
		 while (cur_as_bytes[offset] == compare_value) {
		  // 调用汇编指令cmpxchg执行CAS操作,期望值为cur,更新值为new_val
			 jint res = cmpxchg(new_val, dest_int, cur);
			 if (res == cur) break;
			 cur = res;
			 new_val = cur;
			 new_val_as_bytes[offset] = exchange_value;
		 }
		 // 返回当前值
		 return cur_as_bytes[offset];
}

综上,可以发现,其实CAS实现的就是乐观锁,所以如果我们在代码中使用自旋CAS,那么就可以保证共享变量操作的原子性,之前我们介绍的ConcurrentHashMap,以及LinkedTransferQueue 就是使用这样的方式,下面我摘录了源码:

final V putVal(K key, V value, boolean onlyIfAbsent) {
        //自旋
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                //CAS
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
private E xfer(E e, boolean haveData, int how, long nanos) {
        if (haveData && (e == null))
            throw new NullPointerException();
        Node s = null;                        // the node to append, if needed
        retry:
        for (;;) {                            // restart on append race
            for (Node h = head, p = h; p != null;) { // find & match first node
                boolean isData = p.isData;
                Object item = p.item;
                if (item != p && (item != null) == isData) { // unmatched
                    if (isData == haveData)   // can't match
                        break;
                    //CAS
                    if (p.casItem(item, e)) { // match
        }
    }

关于CAS的详细原理可以参考这篇文章

ps:CAS操作本质也是需要修改共享变量的数据,只是这个修改使用硬件支持的原子性修改指令,这个操作底层还是离不开锁,但是主要涉及的是缓存行锁,这又 涉及了MESI缓存一致性协议,关于这些知识点我也只是看了一些文章了解一些皮毛,有时间再好好研究总结下,这里我记录下几篇参考文章:mesi参考文章1,mesi参考文章2

8.java中CAS原子性操作的封装

既然不推荐直接操作Unsafe,那么java中肯定有相关的CAS操作的封装,这就是java.util.concurrent.atomic包下的实现
atomic包
比如AtomicInteger封装了对int类型数据的cas操作

    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

其他的一样的道理。

9.CAS的问题和优化方案

虽然cas可以做到再代码中不用加同步锁也可以实现操作的原子性,避免了线程上下文切换,但是cas还是有下面几个缺点: (1).单独的cas操作只能正对单个的共享变量,对于多个共享变量的操作就比较麻烦了,需要对这多个共享变量进行整合
(2).cas过程可能出现长时间的cpu循环自旋,那么就会导致资源浪费和耗时
(3).使用cas还是会出现异常的情况,比如ABA问题

下面来详细解释下,首先对于第一点,java中提供了AtomicReference,可以直接将多个共享变量封装到一个对象中,然后使用这个引用原子类操作。 第二点目前再java中只能好jvm对于这样的持续死循环会有相应的优化,第三点,首先需要明白什么是ABA问题,其实很简单,就是如果线程1使用CAS修改 数据A为x1,这期间,线程2先使用cas修改数据A为x2,然后又使用cas将数据A修改回x1,这样线程1的cas操作还是成功的,因为这期间虽然修改了数据,但是 可能因为线程2就是比线程1快很多,在线程1cas执行完之前又将数据修改为x1了,所以线程1不知道,还以为本次操作只有它一个人修改了数据,但事实 上本次的操作已经不是原子性操作了。其实造成这样问题的原因就是数据的唯一性是按照其数值来定义的,所以在java中可以使用AtomicStampedReference来 为数据添加时间戳,这样就能保证数据的真正唯一性。

10.指令重排问题

java会将上下没有执行依赖的代码进行指令重新排序,来保证以何种顺序执行可以达到最优的效率,比如在下面这段代码中:

    public void reSort(){
        int a = 0;//1
        int b = 1;//2
        int c = a + b;//3
    }

上面的程序执行的顺序可能是1->2->3,也可能是2->1->3
对于指令重排,在单线程程序中执行没有任何问题,但是在多线程场景下就可能会出现异常,运行结果和预期不同

        final boolean[] start = {false};
        final int[] num = {0};
        Thread thread01 = new Thread(() -> {
            if(start[0]){
                System.out.println(num[0]);
            }
            System.out.println("over");
        });
        Thread thread02 = new Thread(() -> {
            num[0] = num[0] + 1;//1
            start[0] = true;//2
        });
        thread01.start();
        thread02.start();

对于上面这个例子,可能会打印出num[0],也可能不会打印,因为1和2两处的代码可能会出现指令重排,导致执行顺序不同。
想要避免指令重排导致一些不可预料的问题,可以使用volatile修饰变量,那么在进行指令优化时,不能将volatile关键字后面的语句放 在volatile关键字前面执行,也不能将volatile关键字前面的语句放在volatile关键字修饰的变量操作的后面执行,需要保证顺序

11.总结

synchronized就是java中的同步锁,可以保证内存可见性和操作原子性和指令有序性(使用不同的加锁和释放锁来保证顺序,因为对于同一个锁,第一个加锁一定在后一个相同锁的加锁前执行),但是它会造成线程阻塞。 volatile可以保证线程的内存可见性和指令有序性,但是无法保证操作的原子性。 java中的CAS操作(即乐观锁),可以在不使用用户级的同步锁的情况下实现操作的原子性。