Java中的常见的锁及其内存语义

文章目录

    • 为什么会有锁?
      • JVM内存模型
      • 没有锁会怎么样?
      • happens-before 先行先发生原则
    • Java中常见的锁
      • synchronized
          • 内存语义
          • 实现原理
      • volatile
          • 内存语义
          • 实现原理
      • Lock Api
          • 队列同步器
      • 类初始化锁
      • 单例模式之懒汉式与静态内部类式
          • 基于volatile的解决方案
          • 基于类的初始化的解决方案——静态内部类

为什么会有锁?

为什么Java会有锁,这要从Java的内存模型讲起:
大家都知道Java是个多线程语言,这句话的意思是一个Java进程可以创建多个线程来执行指令。对于可并发的编程语言,要怎么做到线程之间是如何通信的,以及线程间是如何同步的?

  • 在命令式编程中,通信机制一般有两种,共享内存消息传递
    共享内存是一种隐式通信,通过访问一个公共的内存区域,来达到线程间数据交互的作用。
    消息传递是一种显式通信,线程之间必须通过发送接收消息才能进行通信。

  • 线程间的同步是指不同线程间的操作同步,在共享内存模型中,同步是显式的,需要开发人员主动加锁,而在消息传递的模型中,同步是隐式的,因为消息的发送必须在消息的接收之前,这就意味着数据同步。

JVM内存模型

下面再来看看JVM的内存模型(JMM)
Java中的常见的锁及其内存语义_第1张图片
如果线程AB要进行通信,必须经过两个步骤,①线程A把本地内存A的共享变量更新进主内存,②线程B从主内存中读取最新数据到本地内存B
整体来看,线程A和线程B的通信必须经过主内存,JMM通过控制主内存和每个线程本地内存的交互,来提供内存可见性,以此实现通信。

本地内存的使用,使得每个线程不用都去频繁地都去访问主内存,可也正是由于JVM本地内存副本的实现,导致了线程读取的内存数据,可能不是最新的。
所以,对于存在并发竞争的数据,我们得加锁来保证数据一致。

没有锁会怎么样?

public class A {
	// 定义一个静态变量
	public static int a = 0;
}
public static void main(String[] args){
	CountDownLatch end =  new CountDowmLatch(10);
	// 创建10个线程,每个线程都去给a加1,加10次
	for(int i=0; i < 10; i++){
		new Thread(() -> {
			for(int j = 0; j < 10; j++){
				A.a += 1;
			}
			end.countDown();
		}).start();
	}
	// 没有执行完线程前,阻塞
	end.await();
	System.out.println(A.a);
}

上面的代码创建了10个线程并发地去给a变量加1,每个线程加10次。这里的最后打印的结果是小于10的。有就是说,如果不加锁,预期结果和最终结果是不一致的。

happens-before 先行先发生原则

这个概念是后续锁的内存语义的基础,先了解一下JMM的happens-before规则,这几点规则是JMM自己实现的。

  1. 程序顺序规则:一个线程中的操作,happens-before于后面的任意操作。
  2. 监视器锁规则:一个线程的解锁,总是happens-before于随后的对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个域的读
  4. 传递性:如果A happens-before B,B happens-before C,那么A happens-before C
  5. start()规则:如果线程A执行操作ThreadB.start()(启动B线程),那么线程A的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()操作并成功返回,那么线程B的操作happens-before于线程A从ThreadB.join()成功返回

比如看看下面这个案例:
Java中的常见的锁及其内存语义_第2张图片
假如线程A程序次序为步骤1,2,线程B的程序次序为步骤3,4且线程A比线程B先执行,那么根据第3条规则,步骤22一定会比步骤3先发生,并且步骤3能“看到”步骤2的执行,也就是步骤3读到的已经是步骤2修改过的数据了。那么再根据规则1和规则4的传递性,可以知道步骤1也先行发生于步骤4,所以线程B读到的共享变量一定是线程A修改过后的变量。

Java中常见的锁

synchronized

内存语义

Java中的常见的锁及其内存语义_第3张图片
假设线程A执行writer()方法,线程B执行reader()方法,那么会发生如下3种happens-before关系

  1. 根据程序次序,1happens-before2,2happens-before3,4happens-b5,5happens-before6
  2. 根据监视器锁原则(对一个锁的解锁happens-before对这个锁的加锁),3happens-before4
  3. 根据传递原则,2happens-before5
    关系图如下:
    Java中的常见的锁及其内存语义_第4张图片
    当线程释放锁时,JMM会把该线程对应的本地内存的共享变量刷新至主内存中
    当线程获取锁时,JMM会把该线程对应本地内存设置为不可用。
实现原理

synchronized都是将对象作为锁

  • 对于普通方法,是以当前实例对象作为锁
  • 对于静态方法,锁的是字节码对象
  • 对于同步方法代码块,锁的是synchronize括号内的对象

synchronize在JVM的实现原理是基于进入和退出Monitor对象来实现方法同步和代码块同步的,但是两者的实现细节不一样。
代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,但是大体也是可以通过这两个来解析。

JVM保证monitorenter和monitorexit一定是成对出现的,在代码编译后,monitorenter在插入在同步代码块的开始位置,monitorexit则插入在结束位置或者异常位置,
当执行到指令monitorenter的时候,就会去获取锁对象,这个是在编译成class字节码文件时操作的

上面说到,synchronized是以对象作为锁的,那么其实作为锁的对象,是以对象头部的一块区域-MarkWord有关的。
在JDK1.6之后,synchronized进行了优化,引入了“偏向锁”和“轻量锁”,级别由低到高为无锁状态、偏向锁状态、轻量锁状态、重量锁状态
这几个状态会随着锁的竞争而升级,却不会降级,不能降级目的是为了提高获取锁和释放锁的效率。

这里列举了synchronized锁的几种状态的优缺点
Java中的常见的锁及其内存语义_第5张图片
这里不对偏向锁,轻量锁进行冗述。可以直接看《Java并发编程的艺术》第二章第二节。

volatile

内存语义

值得一提的是,我觉得volatile并不能称为一种锁,而是一种对内存控制保持线程可见性的一个内存特性。
上文的happens-before原则里用了volatile的案例,volatile只是对内存操作,也就是数据的读写,进行了加锁同步。

锁的happens-before规则能够保证释放锁和获取锁的两个线程的内存可见性。这意味着对于volatile的变量,在读volatile的时候,总是可以看到最后一个线程写这个变量的数据。
volatile具有如下特性:

  1. 可见性,操作volatile的线程都可以看到其他线程写这个volatile变量的情况
  2. 原子性,对任意单个volatile变量的读或写是原子的,但是对于volatile++这样的复合操作就不具有原子性,因为volatile++包括了3个步骤,取值,加1,设值。

看下面的一个例子:
Java中的常见的锁及其内存语义_第6张图片
当线程A先执行writer(),然后线程B执行reader()方法时,根据happens-before原则,可以建立3种先行先发生关系

  1. 根据程序次序,1先行发生于(happens-before)2;3先行发生于(happens-before)4。
  2. 根据volatile的规则,2happens-before4
  3. 更具传递规则,1先行发生于4

Java中的常见的锁及其内存语义_第7张图片
对于A线程来说,其执行的是writer()方法,在把flag写入缓存后,马上就将本地内存中被A更新过的两个共享变量的值刷新至主存。而在读一个volatile变量的时候,JMM会另本地工作内存不可读,直接从主内存中获取值。以保证数据同步

实现原理

了解一下CPU处理器的一些概念:处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2),然后再进行操作,但是操作后的结果不知道什么时候回写到内存。

如果对变量进行了volatile的修饰,在对变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的缓存回写到内存。并且,为了保证每个处理器缓存都一致,
每个处理器通过嗅探在总线上的传播数据来检查自己的缓存是否过期,如果判断修改,就会主动过期无效自己的缓存,下一次操作就会从主内存中获取。
保证各个处理器缓存一致的行为称为缓存一致性协议。
当一段代码对volatile的变量进行写操作,比如:
在这里插入图片描述
其转为汇编的结果是:
在这里插入图片描述
lock前缀的指令如上所说,会让处理器及时回写内存,并保持缓存一致性,总结为两件事:
将当前处理器的数据回写系统内存
这个回写内存操作会使其他cpu处理器的缓存了该内存地址的数据无效
volatile还能够设置内存屏障,保证不会被指令重排

Lock Api

Lock是JDK1.5之后提供的API,相比synchronized的加锁,Lock提供了更多的选择
Java中的常见的锁及其内存语义_第8张图片
Lock相比synchronized来说,可以非阻塞的获取锁,可以中断的获取锁,可以可超时的获取锁,也可以是公平或者非公平锁。
Lock的实现大多都依靠AQS(AbstractQueuedSynchronizer)队列同步器。
AQS提供的API是使用模板方法建造的,我们开发者可以通过重写AQS的一些方法来实现我们自己的队列同步器。

队列同步器

同步器依赖于内部的一个(FIFO)队列实现,队列里面的内容是封装好的线程节点。当前线程获取同步器失败时,同步器会将当前线程以及等待状态信息构造成一个节点(Node),并将其加入同步队列中,尾插入。同时阻塞当前线程。当同步状态释放时,会把首节点的线程唤醒,使其再次尝试获取同步状态。
这是同步器队列的基本结构,其中左边是头,右边是尾。

插入同步器队列一定是一个高并发的,有资源竞争场景,所以在插入节点的时候一定要保证同步,在同步队列中,采用的是基于CAS的设置尾节点方法:
compareAndSetTail(Node expect, Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功,当前节点才能正式与之前的节点建立关联。
Java中的常见的锁及其内存语义_第9张图片
而首节点设置是不需要同步的,因为首节点的线程释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点,因为这个过程只涉及一个节点一个线程,所以不需要CAS来来保证,只需要将原首节点的后继节点并断开原首节点的next引用即可。
关于AQS更加详细的内容,也请各位读者通过看书,看源码的方式去详细了解,这里不做太多描述

类初始化锁

类的初始化只会执行一次,而且这个是由Class对象的初始化锁保证的
Java中的常见的锁及其内存语义_第10张图片
根据Java语言规范,在首次发生下列任意一种情况时,一个类(抽象类也是类)或者接口类型T会被立即初始化,也就是会立即执行类构造器
而执行类构造器是由编译器保证全局只会执行一次,通过class锁保证同步。

  1. T是一个类,而且一个T类型的实例被创建
  2. T是一个类,且T中声明的一个静态方法被调用
  3. T中声明的一个静态字段被赋值
  4. T中声明的一个静态字段被使用,且这个字段不是常量(静态常量放在常量池中)
  5. T是一个顶级类,且一个断言语句嵌套在T内部执行
  6. 初始化子类时,会初始化其父类

单例模式之懒汉式与静态内部类式

说到单例模式,各位开发者应该都很熟悉,而Java中常见的包括饿汉式,懒汉式等。这里不做介绍,先来列出一段代码:

public class LazySingleInstance {
	private static Instance instance;
	public static Instance getInstance(){
		if(instance == null){
			sysnchronized (LazySingleInstance.class){
				if(instance == null){
					instance = new Instance();
				}
			}
		}
		return instance;
	}
}

这段代码其实是有问题的
因为new Instance() 是可以被重排序的,问题的根源是编译器以单线程来考虑能否重排序。
一个创建对象并赋值的过程包括3步:分配对象的内存空间,初始化对象,设置instance指向刚刚分配的空间
Java中的常见的锁及其内存语义_第11张图片
在Java语言规范中,所有线程在执行操作时,必须遵守intra-thread semantics,这个规则会保证重排序不影响单线程的结果,也就是说,如果对于单个线程允许重排序,那么为了优化执行效率,就会选择重排序。
Java中的常见的锁及其内存语义_第12张图片

而上图中的2,3刚好会被重排序,且对于单线程不影响,但是对多线程来说,这是不合法的,因为在检查instance时,只要内存地址被赋值了,就会不为null,从而造成线程拿到的instance实例是未被正常初始化的。

基于volatile的解决方案

volatile是拥有禁止重排序语义的。只要将instance定义为volatile变量就没问题了
Java中的常见的锁及其内存语义_第13张图片
这是JMM为了实现volatile的语义,会限制前面说的编译器重排序和处理器重排序。上面的表格是针对编译器制定的
如上的表格,总结为以下3点:

  • 对于读一个volatile,后面的操作都不能重排序,也就是说,读volatile后的数据操作都是在读volatile之后
  • 对于写一个volatile,其前面的操作都不会重排序到写volatile之后。
  • 如果第一个操作是写一个volatile数据,那么后面的读volatile数据是不会重排序到其之前。
    实现这个重排序规则主要是JMM利用内存屏障处理
    Java中的常见的锁及其内存语义_第14张图片
基于类的初始化的解决方案——静态内部类

将代码改造为:

public class LazySingleInstanceFactory {
	private static class InstanceHolder{
		public static Instance instance = new Instance();
	}
	public static Instance getSingleInstance(){
		return InstanceHolder.instance;
	}

这里我们的实例对象是在类InstanceHolder中静态赋值的,也就是在类构造器中被赋值,根据上面类构造器锁中的执行类初始化的触发条件,第4点:T中声明的一个静态字段被使用,且这个字段不是常量。
所以这里利用类构造器锁可以保证对象不会被重复创建。

参考资料:《Java并发编程的艺术》

你可能感兴趣的:(多线程,JVM)