【Java并发编程 三】Java并发机制的底层实现(一)

简介: 【Java并发编程 三】Java并发机制的底层实现(一)

本篇Blog我们来学习下Java的底层对并发是如何支持的,也就是Java底层的并发机制到底是什么样的?在JVM系列的Blog我们知道,Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和CPU的指令。

共享变量和资源

什么是共享资源和变量,在JVM模型中来说,就是JVM的堆和⽅法区,这部分内容是所有线程共享的区域:

  • 是所有线程共享的资源,其中堆是进程中最⼤的⼀块内存,主要⽤于存放新创建的对象 (所有对象都在这⾥分配内存)
  • ⽅法区主要⽤于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

堆和⽅法区公有为了保证线程都能共享到堆中创建的对象以及方法区中的内容。在上一篇BLOG中我们阐述了共享变量可变可能引发的问题,可以通过Java底层机制解决:

  • Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
  • Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的
  • 原子操作,如果操作是原子的,那么每次线程执行的就是不可中断的一组指令,在次过程中当然是不可变的。

本篇Blog我们就来看看Java底层如何解决共享资源的同步访问问题。

volatile关键字

在多线程并发编程中synchronized和volatile都扮演着重要的角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的可见性。可见性就是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度

volatile的内存语义

正是因为有了volatile读写的内存语义,才能保证volatile的可见性和禁止指令重排,其读写内存语义如下:

  • volatile写的内存语义如下。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
  • volatile读的内存语义如下。当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量

对volatile写和volatile读的内存语义做个总结。

  1. 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
  2. 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
  3. 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息

整体效果如下:

volatile可见性保障

了解了volatile的内存语义,我们来看下volatile是如何实现共享变量可见性保证的。

共享变量内存不可见性

在JMM内存模型中,分为主内存线程独立的工作内存,Java内存模型规定,

  • 对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存(比如CPU的寄存器),
  • 线程只能访问自己的工作内存,不可以访问其它线程的工作内存。
  • 工作内存中保存了主内存共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中。

如何保证多个线程操作主内存的数据完整性是一个难题,Java内存模型也规定了工作内存与主内存之间交互的协议,定义了8种原子操作:

  1. lock,将主内存中的变量锁定,为一个线程所独占
  2. read,将主内存中的变量值读到工作内存当中
  3. load,将read读取的值保存到工作内存中的变量副本中。
  4. use,将值传递给线程的代码执行引擎
  5. assign,将执行引擎处理返回的值重新赋值给变量副本
  6. store,将变量副本的值存储到主内存中。
  7. write,将store存储的值写入到主内存的共享变量当中。
  8. unclock,将lock加的锁定解除,此时其它的线程可以有机会访问此变量

通过上面Java内存模型的概述,我们会注意到这么一个问题,每个线程在获取锁之后会在自己的工作内存来操作共享变量,操作完成之后将工作内存中的副本回写到主内存,并且在其它线程从主内存将变量同步回自己的工作内存之前,共享变量的改变对其是不可见的。为了提高处理速度,处理器不直接和内存进行通信而是和缓存交互

  • 写命中:当一个线程对共享变量执行完操作将值回写到主内存的时候,如果发现存在缓存行,则直接回写到缓存行后再进行回写主内存操作,但该操作执行时间不可预期
  • 缓存命中:当一个线程对共享变量操作的时候,发现缓存中已经存在了该变量,那么不会从主内存中同步,而是使用缓存中的变量值

这两种情况都会导致线程在操作共享变量时使用了过期的数据。这样会引发不可预期的执行错误

内存可见性的重要性

很多时候我们需要一个线程对共享变量的改动,其它线程也需要立即得知这个改动该怎么办呢?下面举两个例子说明内存可见性的重要性:有一个全局的状态变量boolean open=true,这个变量用来描述对一个资源的打开关闭状态,true表示打开,false表示关闭,假设有一个线程A,在执行一些操作后将open修改为false

//线程A
resource.close();
open = false;

线程B随时关注open的状态,当open为true的时候通过访问资源来进行一些操作:

//线程B
while(open) {
doSomethingWithResource(resource);
}

当A把资源关闭的时候,open变量对线程B是不可见的,如果此时open变量的改动尚未同步到线程B的工作内存中,那么线程B就会用一个已经关闭了的资源去做一些操作,因此产生错误。

volatile如何保证可见性

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。那么volatile如何保证可见性?在每次对变量的赋值操作后,都会在赋值操作后多执行一个lock addl $0X0, (%esp)操作,这个操作相当于一个内存屏障volatile能实现依赖两个原则:

  • 写命中破坏:Lock前缀指令会引起处理器缓存立即回写到内存。如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为缓存锁定,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效,所以该操作是原子的
  • 缓存命中破坏:一个处理器的缓存回写到内存会导致其他处理器缓存无效,其它线程使用前会从主内存刷新该变量值。该写入动作也会引起别的CPU或者别的内核无效化其Cache,这种操作相当于对Cache中的变量做了一次前边介绍的store和write操作,所以通过这样操作,其它线程使用该变量时发现其Cache已经无效,就会从主内存中更新最新值

因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点,这就是volatile实现可见性的方式。需要注意,volatile虽然能保证可见性,也就是线程间看到的变量都是一致的,但是并不能保证操作的原子性

volatile有序性保障

上一篇Blog提到,程序执行过程中会存在指令重排现象,如果一个操作不是原子的,就会给JVM留下重排的机会,所以对共享变量的读写操作很有可能会指令重排。

指令重排问题

在上一篇Blog中已经讲到,单线程会禁止数据依赖的指令进行重排,但是对于不存在数据依赖的指令允许重排,只要最后执行结果一致,这在单线程中没有问题,但是多线程中就会有问题,多线程举个例子:

public class Singleton {
  private static Singleton instance = null;
  private Singleton() { }
  public static Singleton getInstance() {
     if(instance == null) {
        synchronzied(Singleton.class) {
           if(instance == null) {
               instance = new Singleton();  //非原子操作
           }
        }
     }
     return instance;
   }
}

看似简单的一段赋值语句:instance= new Singleton(),但是很不幸它并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:

memory =allocate();    //1:分配对象的内存空间 
ctorInstance(memory);  //2:初始化对象 
instance =memory;     //3:设置instance指向刚分配的内存地址

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:

memory =allocate();    //1:分配对象的内存空间 
instance =memory;     //3:instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory);  //2:初始化对象

可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错,注意这里volatile阻止的并不是 instance = new Singleton(); //非原子操作的重排序,而是保证了在一个写操作完成之前,不会调用读操作 if(instance == null)

volatile禁止指令重排

除了前面内存可见性中讲到的volatile关键字可以保证变量修改的可见性之外,还有另一个重要的作用:在JDK1.5之后,可以使用volatile变量禁止指令重排序,例如的instance以关键字volatile修饰之后,就会阻止JVM对其相关代码进行指令重排,这样就能够按照既定的顺序指执行。同时存在两个对变量的操作的时候,instance =memory; 就是对volatile变量的写,并且在顺序执行里为第二个动作,第一个动作是ctorInstance(memory)

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序

那么volatile依靠什么实现的禁止指令重排呢?那就是内存屏障

内存屏障

volatile关键字通过提供内存屏障的方式来防止指令被重排序,为了实现内存屏障的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。大多数的处理器都支持内存屏障的指令。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存
  • 在每个volatile写操作后插入StoreLoad屏障;对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
  • 在每个volatile读操作前插入LoadLoad屏障;对于这样的语句Load1;LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • 在每个volatile读操作后插入LoadStore屏障;对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

如果编译器无法确定后面是否还会有volatile读或者写的时候,为了安全,编译器通常会在这里插入一个StoreLoad屏障,通过以上的保守策略,volatile禁止指令重排,防止执行出现错误。下面是volatile写操作的内存屏障

下面是volatile读操作的内存屏障,

volatile总结

相对于synchronized块的代码锁,volatile提供了一个轻量级的针对共享变量的锁,当我们在多个线程间使用共享变量进行通信的时候需要考虑将共享变量用volatile来修饰。volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。

  • volatile变量作用类似于同步变量读写操作,从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。
  • volatile不如synchronized安全:在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。
  • volatile无法同时保证内存可见性和原子性:加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。

在需要同步的时候,第一选择应该是synchronized关键字,这是最安全的方式,尝试其他任何方式都是有风险的。尤其在、jdK1.5之后,对synchronized同步机制做了很多优化,如:自适应的自旋锁、锁粗化、锁消除、轻量级锁等,使得它的性能明显有了很大的提升。

不保证原子性

如果运算操作不是原子操作,导致volatile变量的运算在并发下一样是不安全的。依然没法保证volatile同步的正确性。由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,仍需要加锁synchronized或java.util.concurrent中的原子类来保证原子性(如果把一个事务可看作是一个程序,它要么完整的被执行,要么完全不执行。这种特性就叫原子性):

  • 对变量的写入操作不依赖于该变量的当前值(比如a=0;a=a+1的操作,整个流程为a初始化为0,将a的值在0的基础之上加1,然后赋值给a本身,很明显依赖了当前值,即使如果对a的加操作立即对其它线程可见,但是多个线程同时可见,同时更新,会导致在大量循环中的a++达不到预期的值,例如循环100次,值最终更新为75),或者确保只有单一线程修改变量。
  • 该变量不会与其他状态变量纳入不变性条件中。(当变量本身是不可变时,volatile能保证安全访问,比如双重判断的单例模式。但一旦其他状态变量参杂进来的时候,并发情况就无法预知,正确性也无法保障)不变式就是a>5,如果a>b,b是个变量,就不能保证了。

也就是在原子操作时volatile并不能百分百保证。

使用建议

使用volatile时需要注意:

  1. 在两个或者更多的线程需要访问的成员变量上使用volatile。当要访问的变量已在synchronized代码块中,或者为常量时,没必要使用volatile。
  2. 由于使用volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字

对于volatile不能解决的方案,可以使用锁来实现。

性能评价

某些情况下volatile的同步机制比锁要好,但很难量化这种优势。volatile自己和自己比较,它的读操作的性能消耗和普通变量几乎没啥区别,但写操作要慢一些,因为需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。即便如此,在大多数场景下,volatile总开销仍然比锁要低,根据volatile语义是否满足场景来选择。如果情况不合适,就使用传统的synchronized关键字同步共享变量的访问,用来保证程序正确性(这个关键字的性能会随着jvm不断完善而不断提升,将来性能会慢慢逼近volatile)。

synchronized关键字

synchronized,我们谓之锁,主要用来给方法、代码块加锁。当某个方法或者代码块使用synchronized时,那么在同一时刻至多仅有有一个线程在执行该段代码。当有多个线程访问同一对象的加锁方法/代码块时,同一时间只有一个线程在执行,其余线程必须要等待当前线程执行完之后才能执行该代码段。具体表现形式有三种。

  • 普通同步方法,锁是当前方法所属实例对象。
  • 静态同步方法,锁是当前方法所属类的Class对象。
  • 同步方法块,锁是Synchonized括号里配置的对象。

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。在指令层面,同步方法块和同步方法是使用monitorentermonitorexit指令实现的, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

synchronized的实现

通过在方法声明中加入 synchronized关键字来声明 synchronized 方法。如:

public synchronized void getResult();

synchronized关键字表明该方法已加锁,在任一线程在访问改方法时都必须要判断该方法是否有其他线程在“独占”。每个类实例对应一个把锁,每个synchronized方法都必须调用该方法的类实例的锁方能执行,否则所属线程阻塞,方法一旦执行,就独占该锁,直到从该方法返回时才将锁释放,被阻塞的线程方能获得该锁。

synchronized代码块所起到的作用和synchronized方法一样,只不过它使临界区变的尽可能短了,换句话说:它只把需要的共享数据保护起来,其余的长代码块留出此操作,语法如下:

synchronized(object) {  
    //允许访问控制的代码  
}

如果我们需要以这种方式来使用synchronized关键字,那么必须要通过一个对象引用来作为参数,通常这个参数我们常使用为this.

synchronized (this) {
    //允许访问控制的代码 
}

对于synchronized(this)有如下理解:

  1. 当两个并发线程访问同一个对象object中的这个synchronized(this)同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。
  2. 当一个线程访问object的一个synchronized(this)同步代码块时,其他线程对object中所有其他synchronized(this)同步代码块得访问将被阻塞。
  3. 当一个线程访问object的一个synchronized(this)同步代码块时,另一个线程仍然可以访问object中的非synchronized(this)同步代码块。
相关文章
|
1天前
|
数据采集 安全 Java
Java并发编程学习12-任务取消(上)
【5月更文挑战第6天】本篇介绍了取消策略、线程中断、中断策略 和 响应中断的内容
17 4
Java并发编程学习12-任务取消(上)
|
1天前
|
Java 编译器 开发者
Java并发编程中的锁优化策略
【5月更文挑战第15天】 在Java的多线程编程中,锁机制是实现线程同步的关键。然而,不当的锁使用往往导致性能瓶颈甚至死锁。本文深入探讨了Java并发编程中针对锁的优化策略,包括锁粗化、锁消除、锁分离以及读写锁的应用。通过具体实例和性能分析,我们将展示如何有效避免竞争条件,减少锁开销,并提升应用程序的整体性能。
|
1天前
|
消息中间件 安全 前端开发
字节面试:说说Java中的锁机制?
Java 中的锁(Locking)机制主要是为了解决多线程环境下,对共享资源并发访问时的同步和互斥控制,以确保共享资源的安全访问。 锁的作用主要体现在以下几个方面: 1. **互斥访问**:确保在任何时刻,只有一个线程能够访问特定的资源或执行特定的代码段。这防止了多个线程同时修改同一资源导致的数据不一致问题。 2. **内存可见性**:通过锁的获取和释放,可以确保在锁保护的代码块中对共享变量的修改对其他线程可见。这是因为 Java 内存模型(JMM)规定,对锁的释放会把修改过的共享变量从线程的工作内存刷新到主内存中,而获取锁时会从主内存中读取最新的共享变量值。 3. **保证原子性**:锁
16 1
|
1天前
|
Java 开发者
深入理解Java并发编程:从基础到高级
【5月更文挑战第13天】本文将深入探讨Java并发编程的各个方面,从基础知识到高级概念。我们将首先介绍线程的基本概念,然后深入讨论Java中的多线程编程,包括线程的创建和控制,以及线程间的通信。接下来,我们将探讨并发编程中的关键问题,如同步、死锁和资源竞争,并展示如何使用Java的内置工具来解决这些问题。最后,我们将讨论更高级的并发编程主题,如Fork/Join框架、并发集合和并行流。无论你是Java新手还是有经验的开发者,这篇文章都将帮助你更好地理解和掌握Java并发编程。
|
1天前
|
安全 算法 Java
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第13天】 在Java开发中,并发编程是一个复杂且重要的领域。它不仅关系到程序的线程安全性,也直接影响到系统的性能表现。本文将探讨Java并发编程的核心概念,包括线程同步机制、锁优化技术以及如何平衡线程安全和性能。通过分析具体案例,我们将提供实用的编程技巧和最佳实践,帮助开发者在确保线程安全的同时,提升应用性能。
10 1
|
1天前
|
Java 编译器 开发者
Java并发编程中的锁优化策略
【5月更文挑战第13天】在Java并发编程中,锁是一种重要的同步机制,用于保证多线程环境下数据的一致性。然而,不当的使用锁可能会导致性能下降,甚至产生死锁等问题。本文将介绍Java中锁的优化策略,包括锁粗化、锁消除、锁降级等,帮助开发者提高程序的性能。
|
1天前
|
Java API 调度
[AIGC] 深入理解Java并发编程:从入门到进阶
[AIGC] 深入理解Java并发编程:从入门到进阶
|
1天前
|
Oracle Java 关系型数据库
Java 编程指南:入门,语法与学习方法
Java 是一种流行的编程语言,诞生于 1995 年。由 Oracle 公司拥有,运行在超过 30 亿台设备上。Java 可以用于: 移动应用程序(尤其是 Android 应用) 桌面应用程序 网络应用程序 网络服务器和应用程序服务器 游戏 数据库连接 等等!
38 1
|
9月前
|
存储 算法 Java
吐血整理Java编程基础入门技术教程,免费送
吐血整理Java编程基础入门技术教程,免费送
34 0
|
开发框架 Java C语言
Java学习路线-1:编程入门
Java学习路线-1:编程入门
72 0