JUC学习笔记 - 02volatile和synchronized

248 阅读4分钟

写在前面: 上篇文章介绍了线程的几个基础概念。本文接着聊多线程离不开的两个关键字,volatilesynchronized。涉及到CPU和JMM的相关问题,可以先阅读该文章:juejin.cn/post/686749… 同时还引出并简单描述了CASABA等问题。需要注意的是,本文的源码是基于jdk8的,jdk9往后的源码有大幅度改动,但是思想不变,阅读时最好跟着自己的源码进行阅读。

synchronized

synchronized即同步锁,通过锁定对象来实现多线程的同步操作。

synchronized的使用

以一个count++的小程序为例:

Object o = new Object()作为锁,synchronized锁定的是对象o

public class Test {
    private int count = 10;
	private final Object o = new Object();
	
	public void m() {
		synchronized(o) {
			count++;
			System.out.println(Thread.currentThread().getName() + " count = " + count);
		}
	}
}

还可以对this进行锁定。

public class Test {
	private int count = 10;
	
	public void m() {
		synchronized(this) {
			count++;
			System.out.println(Thread.currentThread().getName() + " count = " + count);
		}
	}
}

如果m()为静态方法,没有this对象怎么办?那么可以对Test.class进行锁定,class也是对象。

public class Test {

	private static int count = 10;
	
	public static void m() {
		synchronized(Test.class) {
			count++;
			System.out.println(Thread.currentThread().getName() + " count = " + count);
		}
	}
}

简单的写法:

public class Test {

	private static int count = 10;
	
	public synchronized static void m() {
        count++;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
	}
}

那么为啥要加`synchronized`呢?假如不加会怎么样?

```java

public class Test implements Runnable {

    private int count = 0;

    public /*synchronized*/ void run() {
        count++;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }

    public static void main(String[] args) {
        Test t = new Test();
        for (int i = 0; i < 100000; i++) {
            new Thread(t, "THREAD" + i).start();
        }
    }
}

count预期的结果是100000,但是由于出现了脏读,所以最终count大概率达不到100000。为什么是大概率呢?首先count存储在堆内存中,线程运行后会把count从堆中拷贝到自己线程的工作区,而不是直接从堆内存中读。而从自己线程写回堆内存这个过程是无法控制的。所以是大概率达不到100000.

synchronized可以保证原子性和可见性,这里的可见性是happens-before原则,即释放锁时将线程工作区的数据会及时同步至堆内存,这样就能保证最终结果为100000。

可重入锁

现在有这样一段程序:

public class Test {
	synchronized void m1() {
		System.out.println("m1 start");
		try {
			TimeUnit.SECONDS.sleep(1);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		m2();
		System.out.println("m1 end");
	}
	
	synchronized void m2() {
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println("m2");
	}

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

现有两个方法m1m2都持有同一把锁,在m1中能否调用m2,试想一下会不会出现死锁的情况?答案是不会,m2在申请锁时发现锁定该锁的线程m1m2在同一个线程,那么就允许m2获得该锁。这就是可重入锁。

为什么synchronized是可重入锁呢?看如下例子:

public class Parent {
    synchronized void m() {
        System.out.println("parent m start");
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("parent m end");
    }

    public static void main(String[] args) {
        new Child().m();
    }
}

class Child extends Parent {
    @Override
    synchronized void m() {
        System.out.println("child m start");
        super.m();
        System.out.println("child m end");
    }
}

子类调用super.m(),这是很常见的子类调用父类的场景,如果非可重入,那么就会出现死锁的情况。

几个注意点

  1. 注意对异常情况的处理

由于多线程中如果出现异常,默认情况锁会被释放,在业务场景中通常是有问题的。

public class Test {

    static int count = 0;

    final static Object o = new Object();

    public static void main(String[] args) {

        new Thread(() -> {
            synchronized (o) {
                for (int i = 0; i < 100; i++) {
                    count++;
                    try {
                        int exception = 1 / 0; // 此处抛异常
                    } catch (Exception e) {
                        count--;
                    }
                }
            }
        }, "t1").start();

        new Thread(() -> {
            synchronized (o) {
                for (int i = 0; i < 100; i++) {
                    count++;
                }
            }
        }, "t2").start();
    }
}

如果上述代码中没有catch异常,t1会释放锁,导致t2线程会乱入进来,最终count结果有误。

  1. 不要用基础类型作为锁

例如锁住String str = "123,万一其他类库中也有其他的代码锁定str,可能会出现意想不到的错误。

  1. synchronized锁定的对象最好用final进行修饰。

synchronized的一些底层知识

本节参考:www.itqiankun.com/article/bia…

通过javap -v查看被synchronized修饰的代码块的字节码指令,会发现monitorentermonitorenter分别在同步代码块的开始位置和结束位置。如果有异常未处理则会出现多个monitorenter,这是保证出现异常时也能释放锁。

我们就讨论jdk8的情况,以前讨论JMM的时候提到过markword,即:

markword.jpg

hotsport在32位虚拟机为例,markword占用32bit。在无锁状态时存储的是对象的hashCode;偏向锁时存放线程ID和epoch;轻量级锁时存放线程栈中的LockRecord指针;重量级锁时指向堆中monitor对象的指针。

GC中会提到一个词safepoint,即该状态下所有线程都是暂停的。

偏向锁

当只有一个线程反复获取或释放锁时,获取资源的时候会记录该线程信息,并且偏向锁不会主动释放。每次获取偏向锁时都会判断资源是否偏向自己,偏向自己则不需要额外错做,直接可以进入同步操作。所以在只有一个线程反复获取释放锁时效率会非常高。

偏向锁获取过程:

  1. 访问markword中锁标志位是否为01,偏向标志位是否为1,即是否可偏向,进入步骤2。
  2. 判断markword中线程ID是否指向当前线程,是则进入步骤5,否则进入步骤3。
  3. 通过CAS操作竞争锁,成功则将markword线程ID改为当前线程ID,然后执行步骤5。失败则执行步骤4。
  4. CAS竞争锁失败,当到达safepoint时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,被阻塞在safepoint的线程继续往下执行同步代码。
  5. 执行同步代码。

偏向锁的释放: 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。释放时需要等待safepoint暂停拥有偏向锁的线程,然后按步骤判断:

  1. 线程已经退出了同步代码块,或者不存活,则直接释放偏向锁变为无锁状态。
  2. 线程还在同步代码块中,线程升级为轻量级锁。

epoch和批量重偏向:

  1. 偏向锁状态还会存储epoch信息,它代表了偏向锁的有效性。epoch存储在markword中和class类信息中。
  2. 每当遇到一个safepoint时,比如要对一个锁对象进行批量重偏向,则首先对对象class信息中保存的epoch进行增加操作,得到一个新的epoch_new。
  3. 扫描所有持有锁对象的线程栈,根据线程栈的信息判断出该线程是否锁定了该对象,仅将epoch_new的值赋给被锁定的对象中,也就是现在偏向锁还在被使用的对象才会被赋值epoch_new。
  4. 退出safepoint后,当有线程需要尝试获取偏向锁时,直接检查对象class信息中中存储的epoch值是否与目标对象中存储的epoch值相等, 如果不相等,则说明该对象的偏向锁已经无效了,此时竞争线程可以尝试对此对象重新进行偏向操作。

通过上述过程发现,在存在大量锁对象的创建并高度并发的环境下使用偏向锁,对于性能会有影响。所以在这个并发量大时可以通过-XX:-UseBiasedLocking关闭偏向锁来进行性能优化。

轻量级锁

轻量级锁获取过程:

  1. 在当前线程栈桢中建立LockRecord空间,该空间分为两个部分,分别是displaced hdrowner,并拷贝对象markword到该空间中。
  2. 使用CAS尝试将markword更新为指向LockRecord的指针,并将LockRecord中的owner指针指向markword。更新成功执行步骤3,否则执行步骤4。
  3. 成功拥有该对象的锁,并且markword锁标志位设置为00,即表示处于轻量级锁状态。
  4. 检查markword是否指向当前线程栈帧,是则说明拥有当前线程锁,是重入状态,那么displaced hdr设置为null;否则说明锁对象被其他线程抢占,此时通过自旋等待所,一定次数后仍未获得则升级为重量级锁。

轻量级锁解锁过程: 通过CAS尝试复制LockRecord替换当前markword。成功则同步过程完成;失败则有其他线程尝试获取该锁,需要在释放锁的同时唤醒被挂起的线程。

重量级锁

通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。线程竞争不使用自旋,不会消耗CPU。但是线程会进入阻塞等待被其他线程被唤醒,响应时间缓慢。

volatile

可见性

JVM层面使用了ACC_VOLATILE,而CPU层面使用了MESI保证了高速缓存一致性,详细可参考这篇文章:juejin.cn/post/686749…

Java中的堆内存是所有线程共享里面的内存,同时每个线程都有自己的专属的区域,即自己的工作内存。如果共享内存里有一个值,当某个线程都要去访问该值的时,会将该值拷贝一份到自己的工作区域。然后对这个值的任何操作,首先是在自己的空间里进行改变,何时协会共享内存中无法精确控制。

禁止指令重新排序

在DCL的场景下,代码如下:

public class Test{
  	private static Test INSTANCE;
  	
  	private Test(){
    }
  
  	public static Test getInstance() {
		// 其他业务代码
      	if(INSTANCE == null){
          	synchronized (Test.class) {
              	try{
              	Thread.sleep(1);
                } catch (InterruptedException e){
                  e.printStace();
                }
              INSTANCE = new Mgr05(); 
            }
          	return INSTANCE;
        }
      	public void m() {
			System.out.println("m");
		}
      
      	public static void main(String[] args){
          	for(int i = 0; i < 100; i++){
              	new Thread(()->
                          	System.out.println(Test.getInstance().hashCode())
                          ).start();
            }
        }
    }
}

当第一个线程来了判断INSTANCE确实是空值,然后进行下面的初始化过程,假设第一个线程把这个INSTANCE已经初始化了,第一个线程检查等于空的时候第二个线程检查也等于空,所以第二个线程在if(INSTANCE == null)这句话的时候停住了,暂停之后呢第一个线程已经把它初始化完了释放锁,第二个线程继续往下运行,往下运行的时候它会尝试拿这把锁,第一个线程已经释放了,它是可以拿到这把锁的,此时,拿到这把锁之后还会进行一次检查,由于第一个线程已经把INSTANCE初始化了所以这个检查通过了,它不会在重新new一遍。因次,双重检查是能够保证线程安全的。

那在高并发场景会出现什么问题呢?INSTANCE = new Test()经过我们的编译器编译之后的指令分成三步:

  1. 给指令申请内存;
  2. 给成员变量初始化;
  3. 把这块内存的内容赋值给INSTANCE

不加上volatile可能会导致步骤2和3乱序。既然INSTANCE不为空,另外一个线程发现这个值已经有了,有可能不会进入锁那部分的代码。这导致获取的INSTANCE实例内部数据可能值是错误的。加上volatile则不会出现以上问题。

不保证原子性

还是之前提到的代码:


public class Test implements Runnable {

    private volatile int count = 0;

    public /*synchronized*/ void run() {
        count++;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }

    public static void main(String[] args) {
        Test t = new Test();
        for (int i = 0; i < 100000; i++) {
            new Thread(t, "THREAD" + i).start();
        }
    }
}

上述程序只加了volatile没加synchronized,最终结果大概率也会有问题。说明了volatile不保证原子性,单个关键字无法解决多线程脏读的问题。

CAS

CAS是自旋锁,Java中的Atomic类内部就是由CAS来实现的。以AtomicInteger为例:

    static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    count.incrementAndGet();
                    System.out.println(count.get());
                }
            }).start();
        }
    }

使用Atomic类后无需用synchronized对线程内的方法进行修饰,无需上锁。所以CAS又号称是无所优化。上述代码的关键就是incrementAndGet()方法:

    public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }

进入getAndAddInt方法:

    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;
    }

最终调用的是UnSafe类中的compareAndSwapInt方法。这里的compareAndSwapInt或是jdk9改名为CompareAndSetInt就是我们需要研究的CAS。这些方法均由native修饰,即都是CC++实现的原子操作。字面意思是比较并设置。CAS有三个参数(操作对象, 期望值, 更新值),在代码中封装为4个参数,即(操作对象, 对象地址, 预期值, 更新值)

假设这样调用方法getAndAddInt(obj, var2, 1),当当前线程想将obj加1时,首先得到该值为0,那么在compareAndSwapInt(obj, var2, 0, 0 + 1)方法中期望obj是0,而不是其他值,如果是期望值0则将obj设置为1。如果期望值是1不是0,则重新调用compareAndSwapInt(obj, var2, 1, 1 + 1),期望obj为1,后续工作以此类推。即期望是某个值才进行设置操作,不是期望值则再试一遍或者操作失败。

ABA问题

ABA问题一般是针对对象来说的,加入我拿到的值是obj1,想把它变成obj2,在CAS操作时其他线程将obj1改为了obj3又改回了obj1,此时本线程无法感知到,CAS操作会设置成功,这种情况在实际业务场景可能是有问题的。