第二章 Java并发机制的底层实现原理

2021/5/13 20:55:26

本文主要是介绍第二章 Java并发机制的底层实现原理,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

第二章 Java并发机制的底层实现原理

Java代码经过编译后成为Java字节码文件,字节码被类加载器加载到JVM中,JVM执行字节码,最终转变成汇编指令在CPU上执行。Java中所使用的并发机制依赖于JVM的实现和CPU指令

2.1 volatile的应用

volatile的三个特征:

  1. 保证可见性
  2. 不保证原子性
  3. 禁止指令重排

volatile是轻量级的synchronized,在多处理器开发中保证了共享变量的可见性。可见性指当一个线程改变共享变量时,其他线程可读取到共享变量改变后的值。因此可发现,volatile不会引起线程的上下文切换和调度,适当使用volatile比使用synchronized成本更低。

2.1.1 volatile定义与实现原理

若一个字段被声明为volatile,Java线程内存模型确保所有线程看到这个变量值是一致的。

对volatile修饰的共享变量进行写操作时会多出Lock前缀的汇编代码,作用为:

  1. 将当前处理器缓存的行数据写回到系统内存中
  2. 这个写回内存的操作会使得在其他CPU中缓存了该内存地址的数据无效

当对volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写入到系统内存中,但是在多处理器场景中,为保证各个处理器缓存的是一致的,因此需要缓存一致性协议。每个处理器通过嗅探总线上传播的数据来检查自己缓存的值是否过期,若处理器发现缓存行的内存地址被修改,则将该值设为无效,重新从系统内存中将数据读取到处理器缓存中。

volatile的两条实现原则:

  1. Lock前缀的指令会引起处理器缓存回写到内存

    Lock前缀指令会导致在执行指令期间,声言LOCK#信号。LOCK#可保证多处理器中,处理器通过锁住总线来独占共享内存。但是在最近处理器中,LOCK#通过锁缓存来独占共享内存。在P6和目前处理器中,若访问的内存区域在处理器内部,则不会声言LOCK#信号,而是锁定这块内存区域将其写回到内存中,并使用缓存一致性机制来确保修改的原子性,被称为“内存锁定”。缓存一致性机制会阻止同时修改两个以上处理器换存的内存区域数据。

  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效

    处理器使用嗅探技术来保证它的内部缓存、系统内存和其他处理器的缓存数据在总线上保持一致。例如,一个处理器可通过嗅探其他处理器访问的系统内存和它的内部缓存,若该处理器试图写内存地址,并且该地址处于共享状态,则嗅探的处理器使它的缓存行无效,下次访问该内存地址时强制执行缓存行填充。

2.1.2 volatile的使用优化

LinkedTransferQueue类

追加64字节来提高并发编程效率:

通过使用一个内部类追加了15个变量,一个变量4字节,总共60字节,再加上父类的value变量,总共64字节。原因是目前处理器的告诉缓存行是64字节,这意味着,若一个队列不足64字节,处理器会将他们读到同一个高速缓存行中,若一个处理器试图修改头节点,由于一致性机制,该处理器将会锁定整个缓存行,其他处理器不能访问尾节点,而队列的入队和出队需要不断修改头尾节点,这在多处理器中将会严重影响效率。因此追加到64字节,来避免多个队列的头尾节点被加载到一个缓存行中。

是否使用volatile都应追加到64字节呢? 在以下两种场景中不应使用这种方式

1、缓存行非64字节处理器

2、共享变量不会被频繁写

2.2 synchronized的实现原理与应用

synchronized实现同步的基础:Java中的每个对象都可以作为锁。具体表现为以下3中形式:

  1. 对于普通同步方法,锁的是当前实例对象
  2. 对于静态同步方法,锁的是当前类的Class对象
  3. 对于同步代码块,锁的是synchronized括号里配置的对象

从JVM规范中看到synchronized在JVM的实现原理,JVM使用monitor对象来实现方法同步和代码块的同步,细节不同。代码块的同步使用的是monitorenter和monitorexit,方法同步使用的是另一种方式,但也可使用这两个指令来实现。

monitorenter指令插入在同步代码块的开始位置,monitorexit插入在结束处或者异常处,JVM保证每个monitorenter和monitorexit相配对。任何对象都有一个monitor与之相关联,当一个monitor被持有后,该对象处于锁定状态,线程执行到monitorenter后会尝试获取monitor所有权,尝试获得该锁。

2.2.1 Java对象头

synchronized锁存在Java对象头中。对象头形式分为:

  1. 数组类型,使用3个字宽来存储对象头
  2. 非数组类型,使用2个字宽来存储对象头

32位虚拟机中,1字宽4字节;64位虚拟机中,1字宽8字节。Mark Word在32位虚拟机中为32bit,在64位虚拟机中为64bit

Java对象头:

长度 内容 说明
32/64bit Mark Word 存储对象的HashCode,锁信息等
32/64bit Class Metadata Address 存储对象数据类型的指针,指向类的指针
32/64bit Array length 数组长度(数组类型才有)

32位JVM的Mark Word默认存储结构:

锁状态 25bit 4bit 1bit是否为偏向锁 2bit锁标志位
无锁状态 hashCode 对象分代年龄 0 01

Java对象头中Mark Word中默认存储的是HashCode,分代年龄和锁标记位。由于Mark Word中存储的锁标记位为2bit,因此可能会变换为以下类型:

image-20210416205623596

2.2.2 锁的升级与对比

在Java SE1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可升级但不能降级,目的是为了提高获得锁和释放锁的效率。

1、偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程获得,因此为了减少获得锁的代价,引入了偏向锁。当一个线程访问同步代码块获取锁时,会在对象头和栈帧中存储线程ID,以后在获得和释放该同步代码块时将不需要进行CAS操作,仅简单测试下Mark Word中的是否存储指向当前线程的偏向锁。若成功,则获得锁,若失败,则测试Mark Word中的偏向锁的标识是否设为1(标识为偏向锁),若为1,则尝试使用CAS将对象头的偏向锁指向该线程,否则使用CAS竞争锁。

(1)偏向锁的撤销

偏向锁的撤销使用的是竞争出现才会释放锁的机制,当其他线程尝试竞争时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(此时无执行的字节码)。首选,暂停拥有偏向锁的线程,查询是否线程存活,若不活动,则直接在Mark Word中设置为无锁状态;若仍存活,则对象头中的Mark Word要么重新偏向于其他线程,要么恢复到无锁或标记该线程不适合偏向锁,达到偏向锁撤销的目的。最后唤醒暂停的线程。

image-20210417211422076

(2)关闭偏向锁

偏向锁在Java 6和Java 7中是默认启用的,但须等应用程序启动几秒后才激活,可使用JVM关闭延迟:-XX:BiasedLockingStartupDealay=0.若所有进程都处于竞争状态,则可关闭偏向锁:-XX:UseBiasedLocking=false,进入默认轻量锁状态

2、轻量级锁

(1)轻量级锁加锁

线程在执行同步代码块之前,JVM会先在当前线程的栈中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。若成功,则该线程获得该锁,若失败,则自旋来获取该锁

(2)轻量级锁解锁

轻量级解锁时,首先使用CAS操作将Displaced Mark Word替换回对象头,若成功,表示无竞争;若失败,则表示存在竞争,轻量级锁变为膨胀为重量级锁。

image-20210417213531106

为了避免无用的自旋,一旦轻量级锁升级为重量级锁,便不会再恢复到轻量级锁的状态,若其他线程试图获取锁,都会进入阻塞状态,当持有锁的线程释放锁后,会唤醒其他阻塞的线程,被唤醒的线程又会进入新一轮的竞争。

3、锁的优缺点对比

优点 缺点 使用场景
偏向锁 加锁解锁不需额外损耗,和执行非同步代码块仅存在纳秒级差异 若存在多个线程竞争,撤销锁时会带来额外的损耗 一个线程访问同步块的场景
轻量级锁 竞争的线程不会阻塞,提高响应速度 得不到锁的线程会进入自旋,消耗CPU 追求响应时间,同步块执行速度非常快
重量级锁 竞争的线程不会进入自旋,不会消耗CPU 线程阻塞,需要额外唤醒,响应速度慢 追求吞吐量,同步代码块执行时间较长

2.3 原子操作的实现原理

原子操作意为不可被中断的一个或一系列操作

2.3.1 术语定义

比较并交换:CAS操作需要输入两个值,一个旧值,一个新值,操作期间比较旧值是否发生变化,若未发生变化,则交换为新值,否则不变。

CPU流水线:在CPU中由5-6个不同功能的电路单元组成一条指令,将一条指令分为5-6个电路单元分别执行,从而在一个CPU周期完成一条执行操作,提高运算速度。

内存顺序冲突:内存顺序冲突一般由假共享引起,假共享指的是不同CPU对同一个缓存行的不同位置进行修改,这样会引起CPU操作无效,因此内存顺序冲突时,CPU必须清空流水线。

2.3.2 处理器实现原子操作

处理器可提供总线加锁缓存加锁这两个操作来保证多处理器间的原子性操作。IA-32处理器可保证基本的内存操作的原子性,处理器从系统内存中读取或写入一个字节时,操作是原子的,其余CPU不能访问该内存地址。Pentium6和新的处理器可保证对一个内存行16/32/64位操作是原子的。

(1)使用总线锁来保证原子性

若多个处理器对共享变量进行操作,可能值和预期不同。例如,i初始值为1,两次i++操作后,i的值可能为2,如下:

image-20210418201556513

因此需要总线锁,使用处理器提供的 LOCK # 信号,当一个处理器在总线上对一个共享变量进行操作时,其他处理器的请求被阻塞。

(2)使用缓存锁来保证原子性

由于总线锁会锁住CPU和内存间的总线,因此其他处理器也不能访问其他的内存地址,代价较大,通常使用缓存锁来保证原子性。

缓存锁定是指内存区域被缓存在处理器的缓存中时,在Lock期间被锁定(其他处理器不能修改),当执行锁操作将其写回到内存中时,处理器不在总线上加LOCK #信号,而是修改内部的内存地址,使用缓存一致性来保证操作的原子性。由于缓存一致性会阻止多个处理器修改缓存内存区域的数据,当其他处理器将已被锁定的缓存行写入内存行中时,会引起缓存行无效。(在2.1.1中也有描述一致性协议)

不能使用缓存锁的情况:

1、操作的数据不能被缓存在处理器内部,或者被缓存的数据跨越多个缓存行

2、处理器不支持缓存锁

2.3.3 Java实现原子操作

(1)使用循环CAS实现原子操作

自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。

package com.chapter2;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
    private AtomicInteger atomicInteger = new AtomicInteger();
    private int i = 0;
    public static void main(String[] args) throws InterruptedException {
        final Counter counter = new Counter();
        List<Thread> list = new ArrayList<>();
        long start = System.currentTimeMillis();
        //理论计算结果为100000
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(()->{
                for (int k = 0; k < 1000; k++) {
                    counter.unsafeCount();
                    counter.safeCount();
                }
            });
            list.add(t);
        }
        for (Thread thread : list) {
            thread.start();
        }
        //等待所有线程执行完成
        for (Thread thread : list) {
            thread.join();
        }
        TimeUnit.SECONDS.sleep(1);

        System.out.println(counter.i); //非原子性操作
        System.out.println(counter.atomicInteger.get()); //原子性操作
        System.out.println(System.currentTimeMillis() - start);
    }

    //安全的CAS计数器
    private void safeCount() {
        while (true) {
            int i = atomicInteger.get();
            boolean b = atomicInteger.compareAndSet(i, ++i);
            if (b) {
                break;
            }
        }
    }
    //不安全的计数器
    private void unsafeCount() {
        i++;
    }
}

image-20210418210202040

(2)CAS实现原子操作的三大问题

1、ABA问题

由于在进行CAS操作时会比较是否旧值会发生变化,没有变化则更新,若一个值原本为A,变化为B后又变化为A,这时使用CAS检查时会发现未发生变化。解决该问题的方法是引入版本号,以上变化为1A->2B->3C。Java1.5开始使用AtomicStampedReference类来解决ABA问题,这个类的compareAndSet方法如下,不仅会检查expectedReference是否为预期值,还会检查expectedStamp是否为预期版本号

public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp) {
    Pair<V> current = pair;
    return
        expectedReference == current.reference &&
        expectedStamp == current.stamp &&
        ((newReference == current.reference &&
          newStamp == current.stamp) ||
         casPair(current, Pair.of(newReference, newStamp)));
}

2、循环时间长开销大问题

若JVM能够使用处理器的pause命令,则效率会有一定提高。pause命令有两个作用:1、延迟流行线执行命令,避免CPU消耗过多的执行资源;2、避免退出循环时出现内存顺序冲突,导致处理器流行线被清空

**3、只能保证一个共享变量的原子操作 **

CAS操作只能保证一个共享变量的原子性,当想要对多个共享变量操作时,可加锁。

(3)使用锁机制实现原子操作

锁机制保证了获得锁的线程才能操作锁内部的代码块。根据2.2,有轻量级锁,偏向锁,互斥锁。在JVM中,除了偏向锁外,其余锁的实现方式用的都是循环CAS,即获取锁时,使用循环CAS来得到锁,释放锁时,使用循环CAS来释放锁。



这篇关于第二章 Java并发机制的底层实现原理的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程