Java并发编程的艺术(1-3)学习总结

:本文参考Java并发编程的艺术

第1章 并发编程的挑战

  • 并发编程的目的是让程序运行更快。

1.1 上下文切换

  • CPU通过时间片来循环执行任务。切换进程的时候会保存上一次的状态,方便下次的恢复。

1.1.1 多线程一定快吗

下面的代码一定很快吗?

public class ConcurrencyTest {
    
    
    private static final long count = 10000l;
    public static void main(String[] args) throws InterruptedException {
    
    
        concurrency();
        serial();
    }
    private static void concurrency() throws InterruptedException {
    
    
        long start = System.currentTimeMillis();
        Thread thread = new Thread(new Runnable() {
    
    
            @Override
            public void run() {
    
    
                int a = 0;
                for (long i = 0; i < count; i++) {
    
    
                    a += 5;
                }
            }
        });
        thread.start();
        int b = 0;
        for (long i = 0; i < count; i++) {
    
    
            b--;
        }
        long time = System.currentTimeMillis() - start;
        thread.join();
        System.out.println("concurrency :" + time+"ms,b="+b);
    }
    private static void serial() {
    
    
        long start = System.currentTimeMillis();
        int a = 0;
        for (long i = 0; i < count; i++) {
    
    
            a += 5;
        }
        int b = 0;
        for (long i = 0; i < count; i++) {
    
    
            b--;
        }
        long time = System.currentTimeMillis() - start;
        System.out.println("serial:" + time+"ms,b="+b+",a="+a);
    }
}

image-20211202200309043

  • 很明显不是,可以看看测试结果。这个代码的意思就是a累加,b也累加。第一个方法是b开一个线程去处理,a在主线程里面处理,可以发现循环次数不是很多的时候反而串行的速度更快。

为什么有的时候并发会更慢?

  • 因为线程线程有创建和上下文切换的开销。
  • 也就是说在100w之前的循环次数,主线程和当前线程切换是需要时间和成本的。

1.1.2 测试上下文切换次数和时长

  • Lmbench3可以测试上下文切换的时长。
  • vmstat可以测试上下文切换的次数。
    • cs就是切换的次数
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 127876 398928 2297092 0 0 0 4 2 2 0 0 99 0 0
0 0 0 127868 398928 2297092 0 0 0 0 595 1171 0 1 99 0 0
0 0 0 127868 398928 2297092 0 0 0 0 590 1180 1 0 100 0 0
0 0 0 127868 398928 2297092 0 0 0 0 567 1135 0 1 99 0 0

1.1.3 如何减少上下文切换

  • 无锁并发编程,CAS算法,使用最少线程和使用协程都是可以的。
    • ·无锁并发编程:避免使用锁
    • CAS算法:Atomic包下面使用的是CAS算法更新数据,不需要加锁
    • 使用最少线程:任务少没有必要创建很多线程,导致很多线程都是在等待CPU的时间。
    • ·协程:单线程里面维持多个任务间的切换。

1.2 死锁

package juctest;


public class DeadLockDemo {
    
    
    private static String A = "A";
    private static String B = "B";
    public static void main(String[] args) {
    
    
        new DeadLockDemo().deadLock();
    }
    private void deadLock() {
    
    
        Thread t1 = new Thread(new Runnable() {
    
    
            @Override
            public void run() {
    
    
                synchronized (A) {
    
    
                    try {
    
     Thread.currentThread().sleep(2000);
                    } catch (InterruptedException e) {
    
    
                        e.printStackTrace();
                    }
                    synchronized (B) {
    
    
                        System.out.println("1");
                    }
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
    
    
            @Override
            public void run() {
    
    
                synchronized (B) {
    
    
                    synchronized (A) {
    
    
                        System.out.println("2");
                    }
                }
            }
        });
        t1.start();
        t2.start();
    }
}

  • 上面就是t1和t2争夺A和B资源的一个死锁状态的程序。
  • 避免死锁的几个方法
    • 避免一个线程同时获取多个锁
    • 避免线程在锁内占用多个资源,尽量保证一个锁只占用一个资源
    • 尝试使用定时锁lock.tryLock(timeout)代替内部锁机制。
    • 对于数据库锁,加锁和解锁必须在同一个数据库连接上面。否则会出现解锁失效。

1.3 资源限制的挑战

  1. 什么是资源限制

程序的执行速度受限于计算机硬件或者是软件资源。

  1. 资源限制引发的问题

如果资源不足的情况下面导致并发执行变为了串行执行,开启并发线程速度可能会很慢。因为上下文切换占用了大量的时间。

  1. 如何解决资源限制的问题
  • 搭建集群
  • 资源复用。比如连接池。
  1. 在资源限制情况下进行并发编程

如何在资源受限的情况下让程序执行更快?根据资源的限制调整程序的并发度。

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

  • java代码编译变成java字节码。字节码被类加载器加载到JVM,JVM执行字节码转换为汇编指令在CPU上面执行。java并发的机制依赖于JVM的实现和CPU指令。

2.1 volatile的应用

  • volatile是轻量级的synchronized,保证多处理器开发的共享变量的可见性。
  • 可见性的意思是一个线程修改共享变量的时候,其它线程可以读到这个值。
  • volatile的成本比synchronized成本更低,不会引起线程的上下文切换和调度。

1.volatile的定义与实现原理

  • 如果一个字段被声明为volatile,那么java线程的内存模型确保所有线程看到这个变量的值是一致的。
  • volatile相关的CPU术语介绍,在下面。

image-20211202204507956

volatile是如何来保证可见性的呢?

  • java代码如下,做一个JIT编译器生成的汇编指令来分析。
instance = new Singleton(); // instance是volatile变量
  • 转变成汇编代码,如下。
0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp);
  • volatile共享变量进行写操作的时候多出第二个汇编的代码。
  • lock前缀的指令在多核处理器的引发的事件。
    • 把当前的处理器缓存行的数据写到系统的内存
    • 这个写回内存的操作会使其它CPU里面缓存的该地址的数据无效。
  • 即使写回了内存,其它的处理器缓存的数据其实还是旧的。这样操作就会出现问题。
    • 所以为了解决这个问题,保证处理器的缓存一致,实现了缓存一致性的协议。
    • 每个处理器通过嗅探总线传播的数据,检查自己的缓存是不是过期了。
    • 如果发现过期了那么就把缓存行设置为无效的状态。并且重新去内存读取数据。

下面来具体讲解volatile的两条实现原则。

  1. Lock前缀指令会引起处理器缓存写回到内存。lock信号可以确保线程能够独占共享内存,它会锁定缓存。这个操作就是缓存锁定。
  2. 一个处理器的缓存写回内存导致其它处理器的缓存无效。处理器通过MESI协议保证内部的缓存和其它处理器的一致性。处理器能够通过嗅探保证内部缓存,其它处理器的缓存和内存是一致的。

2.volatile的使用优化

  • 下面是LinkedTransferQueue的代码
/** 队列中的头部节点 */
private transient f?inal PaddedAtomicReference<QNode> head;
/** 队列中的尾部节点 */
private transient f?inal PaddedAtomicReference<QNode> tail;
static f?inal class PaddedAtomicReference <T> extends AtomicReference T> {
    
    
// 使用很多4个字节的引用追加到64个字节
        Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
        PaddedAtomicReference(T r) {
    
    
        super(r);
        }
        }
public class AtomicReference <V> implements java.io.Serializable {
    
    
    private volatile V value;
// 省略其他代码

上面的LinkedTransferQueue是如何追加字节来优化性能的?

  • LinkedTransferQueue使用内部类PaddedAtomicReference来定义了头结点和尾巴节点。
  • 这个内部类做的事情就是把共享变量拓展追加了64个字节。父类变量4个字节,加上15个4字节的引用变量。
  • 下面的描述其实都是基于volatile修饰的LinkedTransferQueue

为什么追加64字节能够提高并发编程的效率呢?

  • 因为对于大部分的处理器来说L1、L2、L3缓存的高速缓存行是64个字节宽。不支持部分填充缓存。如果队列的头结点和尾部节点都不是64个字节的话,那么他们就会被读到同一个高速缓存行。
  • 如果头结点和尾巴节点在同一个缓存行的时候,那么如果要修改头部节点,这个锁定了头部节点(MESI),而且尾部节点也在同一行,那么其他处理器是无法修改尾部节点的,问题是队列的入队和出队操作都是要修改头部和尾部节点,那么这个就会影响到队列的入队和出队的操作。
  • 所以要避免头结点和尾部节点加载到同一个缓存行。

那么是不是在使用volatile变量时都应该追加到64字节呢

  • 不是,下面的两种场景。
    • 缓存行非64字节宽的处理器
    • 共享变量不会被频繁地写,那么就没有必要读入那么多字节进缓存了。

总结

  • volatile的底层原理其实就是通过lock信号和MESI协议通知所有的处理器缓存失效,并且把数据更新到了内存。
  • 并且提出了LinkedTransferQueue使用volatile的时候一些优化方式,通过添加字节,避免这个头和尾节点一起被锁住,导致很难让别的线程来插入和删除。

2.2 synchronized的实现原理与应用

  • java se1.6的时候给synchronized引入偏向锁和轻量级锁,还有就是锁存储结构和升级的过程。
  • synchronized实现同步到基础
    • 普通方法锁的是当前实例对象
    • 静态同步方法锁的是Class对象
    • 同步方法块,锁住的是synchronized括号里面的对象。
  • 线程访问同步代码的时候必须要得到锁

那么锁到底存在哪里呢?锁里面会存储什么信息呢?

  • synchronized在JVM实现的原理,JVM基于进入和退出Monitor对象实现方法同步和代码块同步。两者实现的细节不同。
    • 代码块使用的是monitorenter和monitorexit
    • 方法同步是另一种方式实现的。
    • monitorenter指令编译后插入到同步代码快的开始位置,monitorexit插入到方法的结束和异常处。
    • 任何对象都有一个monitor关联,

2.2.1 Java对象头

  • synchronized用的锁存在对象头上面。

image-20211202214113241

  • mark word存储的是对象的HashCode,分代年龄,锁标记位。

image-20211204171841460

  • mark word的变化可能随着锁标志位的变化而变化。

image-20211204171927403

image-20211204171959178

2.2.2 锁的升级与对比

1.偏向锁

  • 锁如果不存在多线程的竞争,经常是某个线程获取。所以为了让某个线程获取锁的代价更低,引入了偏向锁。
  • 线程访问获取锁的时候,会在对象头和栈帧的锁记录,存储的锁偏向的线程ID,那么这个时候线程获取锁的时候就不需要去CAS来加锁和解锁了。只需要检测一下Mark Word里面的线程ID。
  • 偏向锁的撤销。
    • 偏向锁是等待要竞争的时候才会释放锁的机制。
    • 如果有其他线程竞争锁,那么首先是暂停持有锁的线程,并且检查是不是存活,如果是拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向其他线程。要么就是恢复到无锁,或者标记对象不适合作为偏向锁。最后唤醒暂停的线程。
    • 如果持有的线程结束,那么就标记为无锁的状态。
    • 另一个线程如果CAS失败那么就会暂停持有的线程,持有的线程会解锁,并且把锁的线程ID设置为空。然后恢复线程。

image-20211204172803125

image-20211204172813509

2.轻量级锁

  • 轻量级加锁
    • 线程执行同步块之前,JVM会在线程里面创建锁记录的空间,并且把对象头的Mark Word复制到锁记录中。
    • 然后通过CAS来把Mark Word替换为指向锁记录的指针。
    • 如果成功那么就获取到轻量级的锁。
    • 如果失败说明有其他线程竞争,那么线程就尝试自旋获取锁。
  • 轻量级解锁。
    • 通过CAS把Mark Word恢复到对象头。
    • 如果成功,那么说明没有竞争,失败说明有竞争。这个时候释放锁,并且唤醒等待的线程。因为已经锁膨胀所以Mark Word指向的是重量级锁的
    • 如果失败那么就会膨胀为重量级的锁。这个时候竞争的线程2会把锁改为10也就是重量级锁。

image-20211204173313120

image-20211204173323447

3.锁的优缺点对比

  • 偏向锁,加锁和解锁都不需要消耗。但是如果产生了锁竞争的问题,就会导致锁撤销。
  • 轻量级锁,竞争的线程不会阻塞,但是一直得不到锁,自旋就会消耗CPU
  • 重量级锁,线程竞争不使用自旋,不会消耗CPU。线程阻塞,导致响应的时间变慢。

image-20211204175402664

2.3 原子操作的实现原理

1.术语定义

image-20211204175614261

2.处理器如何实现原子操作

  • 32位IA-32处理器使用基于对缓存加锁的方式实现多处理器之间的原子操作。意思就是处理器读取某个字节,其它处理器是无法访问的。

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

    • 下面的情况就是因为交错获取内存的i数据到各自处理器的缓存,导致最后的计算更新覆盖问题。
    • 也就是说,要解决这个问题,就是cpu1操作i=1这个数据的时候必须要进制cpu2操作这个数据的缓存。
    • 这个时候通知cpu2不能够操作就是通过总线完成的。

    image-20211204175812294

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

    • 通过缓存锁定来保证原子性。总线锁把CPU和内存之间的通信都锁住了。所以可以通过缓存锁来解决问题。
    • 缓存锁定就是锁定对应的缓存行。

3.Java如何实现原子操作

  • (1)使用循环CAS实现原子操作
    • 实际上就是检查和赋值合成一个原子操作。
    • 通过CMPXCHG指令完成自旋。
  • (2)CAS实现原子操作的三大问题
    • ABA问题,这个可以使用AtomicStampedReference来解决。所谓的ABA问题就是,一个线程准备修改i=1的数据变成i=2。但是在这之前,有线程A把i=1修改为i=2,而且还有线程B把i=2修改回i=1,但是当前线程C不知道被修改了两次,所以能够正常修改。AtomicStampedReference的CAS增加了一个标志位的检查,保证了这次修改的唯一性。
    • 循环时间长开销大。如果CAS不成功,就会一直自旋。可以使用JVM的parse指令,延迟流水线的执行指令,避免退出循环的时候的内存顺序冲突引起的CPU流水线被清空。
    • 只能保证一个共享变量的原子操作,对于多个变量它是无法处理的。或者是通过AtomicReference。
  • (3)使用锁机制实现原子操作
    • 锁机制保证了只有获取锁的线程才能够操作锁定的内存区域。

第3章 Java内存模型

3.1 Java内存模型的基础

3.1.1 并发编程模型的两个关键问题

  • 线程之间如何通信
  • 线程之间如何同步
  • 线程之间的两个通信机制
    • 共享内存
      • 在共享内存的并发模型,通过读写内存的公共状态来进行隐式的通信
    • 消息传递
      • 消息传递必须通过发送消息显式通信。
  • 同步指的是控制不同线程之间操作的相对顺序的机制。

3.1.2 Java内存模型的抽象结构

  • Java线程之间的通信由Java内存模型控制。JMM决定一个线程对共享变量的写入何时对另一个线程可见。
  • 线程之间的共享变量存储到主内存,每一个线程都有自己的私有本地内存。

image-20211204181726837

上图的A和B如何通信

  • A必须要把更新的数据写入到主内存,那么B才能够读取主内存,并且知道A干了什么。
  • 这个通信的过程必须通过主内存。

3.1.3 从源代码到指令序列的重排序

重排序的类型

  • 编译器优化的重排序。
  • 指令级并行的重排序:现代处理器的指令并行技术。
  • 内存系统的重排序:处理器的缓存和读写缓冲器的不一致。

3.1.4 并发编程模型的分类

  • 现代处理器使用写缓冲器临时保存内存写入的数据。写缓冲区保证指令流水线持续运行,避免处理器停顿下来等待内存写入数据造成的延迟。
  • 同时通过批处理方式刷新写缓冲区,以及合并写缓冲区对同一内存地址的多次写,减少对内存总线的占用。

image-20211204182344563

image-20211204182411547

  • 处理器A和B都同时把共享变量的修改写入到写缓冲区(A1,B1),然后从内存读取读取另外一个变量(A2,B2)。最后才把自己写的数据刷新到内存(A3,B3)。所以才会造成的乱序执行。
  • 也就是处理器A和B把数据刷新到内存这个更新才算是完成,才能够被双方读取。也就是A的顺序变成了A2->A1。
  • 原因就是写缓冲区只对当前处理器可见。

3.1.5 happens-before简介

  • 程序顺序规则:线程每个操作都是先于后面的执行操作。
  • 监视器锁规则:unlock之后才能lock
  • volatile变量规则:写一定是先于读的。
  • 传递性
  • happens-before并不要求第一个操作一定是在第二个操作之前执行,但是要求第一个操作一定是对第二个操作是可见的。

image-20211204183135892

3.2 重排序

3.2.1 数据依赖性

  • 如果两个操作对一个共享变量操作,而且有一个是写操作,那么两个操作就是数据依赖的。
  • 编译器和处理器都是遵循数据依赖性的。

image-20211204183255627

3.2.2 as-if-serial语义

  • as-if-serial语义就是不管怎么重排序,执行结果都是不会变的。
  • 所以编译器和处理器不会对数据依赖的指令进行重排序

image-20211204183646911

3.2.3 程序顺序规则

  • 只要是指令之间有可见性的关系,那么就不能够重排序。如果前一个操作不需要对后面的操作可见,那么就可以重排序。

3.2.4 重排序对多线程的影响

  • 重排序可能会把多线程执行的结果修改。
  • 下面的例子就是A执行同一个实例writer和B执行reader,由于a和flag之间不需要互相可见,所以指令是能够被重排序的。也就是可能导致下面的结果。
class ReorderExample {
    
    
  int a = 0;
  boolean flag = false;
  public void writer() {
    
    
    a = 1; // 1
    flag = true; // 2
  }
  Public void reader() {
    
    
    if (flag) {
    
     // 3
      int i = a * a; // 4
……
    }
  }
}

image-20211204184138043

  • 下面是操作3和4交换的时序。也就是提前读取了a的值,导致最后还是出错。这个问题导致是因为处理器的分支预测。能够先去执行下面的a先,然后再执行if。并且通过重排序缓冲按照顺序写入到结果。

image-20211204184400610

3.3 顺序一致性

3.3.1 数据竞争与顺序一致性

  • Java内存模型对数据竞争的定义
    • 一个线程写一个变量
    • 另一个线程读取同一个变量
    • 读写没有通过同步来排序。
  • JMM对正确同步多线程程序内存一致性的保证
    • 如果正确同步,程序执行具有顺序一致性。

3.3.2 顺序一致性内存模型

  1. 一个线程的所有操作必须按照程序的顺序来执行
  2. 所有线程只能看到一个单一的执行顺序。保证每个操作都是对其他线程可见。

image-20211204184844870

  • 顺序一致性模型有一个单一的全局内存。可以通过一个开关来连接不同的线程。
  • 相当于就是把线程的执行变成了串行化。
  • 下面的例子就是A和B线程的一个执行顺序,以及通过监视器锁来保证同步。

image-20211204185030465

  • 如果是没有做到同步的话。那么程序就是无序执行,但是每个线程都能够看到执行顺序,因为每个操作必须对线程可见,这个就是顺序一致性的内存模型。

image-20211204185127485

3.3.3 同步程序的顺序一致性效果

  • 下面的例子
class SynchronizedExample {
    
    
  int a = 0;
  boolean flag = false;
  public synchronized void writer() {
    
     // 获取锁
    a = 1;
    flag = true;
  } // 释放锁
  public synchronized void reader() {
    
     // 获取锁
    if (flag) {
    
    
      int i = a;
……
    } // 释放锁
  }
}

  • A执行writer之后,B执行reader。
    • 对于JMM来说只是里面的数据不能够重排序到外面,但是内部可以重排序
    • 对于顺序一致性模型来说就是都只能够按照程序顺序来执行

image-20211204185449703

3.3.4 未同步程序的执行特性

  • 对于未同步的线程,JMM只是保证提供最小安全性。也就是线程读到要么是默认值,要么是之前写入的值。
  • 但是JMM不保证未同步的程序执行结果与该程序的顺序一致性模型的执行结果是一致的。因为结果一致需要做的就是禁止编译器和处理器的大部分重排序优化。这对程序的执行性能产生很大的影响。
  • 顺序一致性模型和JMM的差异
    • 顺序一致性模型保证单线程内的操作是顺序执行,但是JMM不保证
    • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,JMM不保证。
    • JMM不保证对64位的long和double变量的写操作是原子的,但是顺序一致性模型对所有的内存读写操作都具备原子性。
  • 造成第三个问题的原因就是处理器的工作机制。

image-20211204190146087

  • 总线工作机制可以说是串行化,只允许一个处理器完成内存的访问。保证了读写的原子性。
  • 由于有的处理器是32位,所以java不要求jvm对于long和double的读写都是原子操作的。
  • 下面的例子就说明,如果读写long或者是double,先写32位,然后另一个线程读取,可能导致的数据很奇怪。

image-20211204190431420

3.4 volatile的内存语义

3.4.1 volatile的特性

  • 理解volatile需要把一个volatile的单个读写看成是同一个锁对单个读写操作做了一个同步。
  • 下面的例子。
class VolatileFeaturesExample {
    
    
  volatile long vl = 0L; // 使用volatile声明64位的long型变量
  public void set(long l) {
    
    
    vl = l; // 单个volatile变量的写
  }
  public void getAndIncrement () {
    
    
    vl++; // 复合(多个)volatile变量的读/写
  }
  public long get() {
    
    
    return vl; // 单个volatile变量的读
  }
}
//上面的程序和下面的等价。
class VolatileFeaturesExample {
    
    
  long vl = 0L; // 64位的long型普通变量
  public synchronized void set(long l) {
    
     // 对单个的普通变量的写用同一个锁同步
    vl = l;
  }
  public void getAndIncrement () {
    
     // 普通方法调用
    long temp = get(); // 调用已同步的读方法
    temp += 1L; // 普通写操作
    set(temp); // 调用已同步的写方法
  }
  public synchronized long get() {
    
     // 对单个的普通变量的读用同一个锁同步
    return vl;
  }
}

  • 普通变量通过锁来完成同步,它的执行结果和volatile一样。
  • 锁的happens-before保证了unlock在lock之前执行,也就是保证了unlock之前的操作对lock之后的操作可见。也就是volatile的读操作,必须是能够看到volatile变量的最后的写入。
  • 锁语义决定临界区具有原子性。volatile也能让单个读写操作具备原子性,但是多个volatile操作或者是volatile++就不能保证了。
  • volatile的特性
    • 可见性,保证读操作总能看到最后的写操作的数据。
    • 原子性,除了复合的操作。

3.4.2 volatile写-读建立的happens-before关系

  • volatile的写-读和锁的释放-获取有相同的内存效果。
class VolatileExample {
    
    
  int a = 0;
  volatile boolean flag = false;
  public void writer() {
    
    
    a = 1; // 1
    flag = true; // 2
  }
  public void reader() {
    
    
    if (flag) {
    
     // 3
      int i = a; // 4
……
    }
  }
}
  • 线程A执行writer,线程B执行reader。
  • 执行顺序:1->2,3->4
  • 根据volatile规则:2->3
  • 根据传递性的规则:1->4
  • 箭头都是表示happens-before。
  • 也就是说2之前的所有操作对3和4可见。

image-20211204192011715

3.4.3 volatile写-读的内存语义

  • 当写volatile的时候,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
  • 读的时候一定是读取最新的值。
  • 比如上面的例子
    • 线程A执行writer,那么就会发送给将来要读volatile的线程一个消息,也就是数据修改过的消息
    • 线程B读取volatile的时候发现被修改,数据无效,那么就去内存中取
    • 线程A写,线程B读,实质就是A通过主内存向B发送消息。

3.4.4 volatile内存语义的实现

  • 实现volatile的关键就是限制编译器和处理器的重排序问题。
    • 第二个操作是volatile写的时候,不管第一个操作是什么都不能重排序。
    • 第一个操作是volatile读的时候,不管第二个操作是什么都不能重排序到volatile读操作的上面。
    • 第一个操作是volatile写的时候,第二个操作是volatile读的时候,不能重排序。

image-20211204193047145

  • 保守策略的JMM内存屏障插入策略。
    • 在每个volatile写操作的前面插入一个StoreStore屏障
    • 在每个volatile写操作的后面插入一个StoreLoad屏障
    • 在每个volatile读操作的后面插入一个LoadLoad屏障
    • 在每个volatile读操作的后面插入一个LoadStore屏障
  • 下图的volatile写的两个屏障,第一个StoreStore屏障禁止了上面的普通写和下面的volatile写重排序。也保证前面的写操作对任意的处理器可见。StoreStore把数据刷新到了内存。
    • StoreLoad屏障保证了上面的volatile写不能和下面可能出现的volatile读/写重排序。
    • 一个是处理普通读写,一个是处理volatile的读写。
    • 第二个屏障是在写之后,读之前。最后是选择了volatile写之后,防止读线程过多导致插入的屏障太多影响性能。

image-20211204193358012

  • 下面的例子是volatile读的屏障
    • 第一个LoadLoad屏障是为了保证下面的读操作和volatile读操作不会重排序。
    • LoadStore是为了禁止下面的写操作和上面的volatile读重排序。

image-20211204193857116

演示的例子

  • 可以看到屏障并不是随便加入的,而是经过优化的。
  • 多个volatile读的情况,最后一个读才会加上LoadStore屏障。
  • 多个volatile写,最后一个volatile写才会加上StoreLoad屏障。
class VolatileBarrierExample {
    
    
  int a;
  volatile int v1 = 1;
  volatile int v2 = 2;
  void readAndWrite() {
    
    
    int i = v1; // 第一个volatile读
    int j = v2; // 第二个volatile读
    a = i + j; // 普通写
    v1 = i + 1; // 第一个volatile写
    v2 = j * 2; // 第二个 volatile写
  }// 其他方法
}

image-20211204194203137

X86处理器如何实现volatile的内存语义

  • 由于X86只会对写-读重排序,所以下面volatile写可以省略StoreStore屏障。
  • volatile读直接省略两个屏障。

image-20211204194615647

3.4.5 JSR-133为什么要增强volatile的内存语义

  • 旧的volatile是允许和普通变量重排序的。
  • 导致的问题就是线程B读取的数据并不是最新的。
  • volatile的语义就没有了锁的释放和获取的语义。

image-20211204194835471

3.5 锁的内存语义

3.5.1 锁的释放-获取建立的happens-before关系

  • 下面的例子
    • 线程A先执行了writer然后线程B执行reader。
    • 根据程序的规则1->2,2->3,3->4,4->5,5->6(箭头是happens-before)
    • 根据监视器,3->4
    • 根据传递性2->5。
class MonitorExample {
    
    
  int a = 0;
  public synchronized void writer() {
    
     // 1
    a++; // 2
  } // 3
  public synchronized void reader() {
    
     // 4
    int i = a; // 5
……
  } // 6
}

image-20211204195411767

3.5.2 锁的释放和获取的内存语义

  • 线程释放锁的时候,JMM会把该线程对应的本地内存共享变量刷新到主内存。
  • 线程获取锁的时候,JMM会把线程的本地内存设置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
  • 可以看到锁释放的内存语义和volatile写的是一样的。获取锁语义和volatile读是一样的。
    • 线程A释放锁,实质是线程A对某个要获取锁的线程发出要对共享变量修改的消息
    • 线程B获取锁,实质是线程B接收了某个线程发出的修改消息
    • 线程A释放锁和线程B获取锁,实质上是线程A通过主内存向线程B发送消息。

3.5.3 锁内存语义的实现

  • 这里的ReentrantLock依赖的是Sync的volatile修饰的同步状态来完成的锁。
import java.util.concurrent.locks.ReentrantLock;

class ReentrantLockExample {
    
    
  int a = 0;
  ReentrantLock lock = new ReentrantLock();
  public void writer() {
    
    
    lock.lock(); // 获取锁
    try {
    
    
      a++;
    } f inally {
    
    
      lock.unlock(); // 释放锁
    }
  }
  public void reader () {
    
    
    lock.lock(); // 获取锁
    try {
    
    
      int i = a;
……
    } f inally {
    
    
      lock.unlock(); // 释放锁
    }
  }
}

image-20211204200226998

  • ReentrantLock分为公平和非公平,先从公平来说。
  • 下面就是获取同步状态,加锁。
protected final boolean tryAcquire(int acquires) {
    
    
final Thread current = Thread.currentThread();
        int c = getState(); // 获取锁的开始,首先读volatile变量state
        if (c == 0) {
    
    
        if (isFirst(current) &&
        compareAndSetState(0, acquires)) {
    
    
        setExclusiveOwnerThread(current);
        return true;
        }
        }
        else if (current == getExclusiveOwnerThread()) {
    
    
        int nextc = c + acquires;
        if (nextc < 0)
        throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
        }
        return false;
        }
        
  • 同样unlock解锁的时候也是需要读取状态。
protected final boolean tryRelease(int releases) {
    
    
        int c = getState() - releases;
        if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
        boolean free = false;
        if (c == 0) {
    
    
        free = true;
        setExclusiveOwnerThread(null);
        }
        setState(c); // 释放锁的最后,写volatile变量state
        return free;
}
  • 获取锁的时候需要先读取state,释放锁写的volatile的时候,必须是之前的可见共享变量。

那么公平和非公平锁的内存语义实现

  • 这个是原子操作来更新state变量。
protected final boolean compareAndSetState(int expect, int update) {
    
    
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
  • 那么CAS是如何同时具备volatile的读写内存语义?

    • 也就是CAS的语义是不允许CAS前和CAS后的任意内存操作重排序。

    • 这里是因为sun.misc.Unsafe类的compareAndSwapInt()方法,它是一个本地方法。

    • 如果是多程序执行,那么就会为程序的cmpxchg指令加上lock前缀,如果不是那么就能够省略。

    • lock前缀的说明

      • 确保内存的读-改-写操作原子操作执行,通过缓存或者是总线锁定。
      • 禁止这个指令的前后内存操作重排序。
      • 把写缓冲区的所有数据刷新到了内存。
      • 上面的第二第三点就是完成了volatile的读和写的内存屏障。
  • 对公平和非公平锁的内存语义总结

    • 公平和非公平锁释放都要写一个volatile变量state
    • 公平锁获取的时候,首先去读volatile变量。
    • 非公平锁获取的时候,先用CAS更新volatile变量。这个操作具有volatile和volatile写的内存语义。
  • 锁释放和获取的内存语义

    • 利用volatile变量的写-读所具有的内存语义
    • 利用CAS所附带的volatile读和volatile写的内存语义。

3.5.4 concurrent包的实现

  • volatile读写操作+CAS可以实现线程的通信,这些特性整合起来就是能够实现concurrent包。
  • 首先声明共享变量是volatile。
  • 配合volatile读写,CAS具有的volatile的读写内存语义实现线程的通信。
  • AQS,非阻塞数据结构和原子变量类。concurrent包的基础类都是通过这种模式实现的。

image-20211204203323420

3.6 final域的内存语义

3.6.1 final域的重排序规则

  1. 构造函数对于final域的写入,与随后把这个被构造对象的引用赋值给引用变量。这两个操作不能重排序。
  2. 初次读final的对象引用,与随后初次读这个final域,两个操作不能重排序。
public class FinalExample {
    
    
  int i; // 普通变量
  final int j; // final变量
  static FinalExample obj;
  public FinalExample () {
    
     // 构造函数
    i = 1; // 写普通域
    j = 2; // 写final域
  }
  public static void writer () {
    
     // 写线程A执行
    obj = new FinalExample ();
  }
  public static void reader () {
    
     // 读线程B执行
    FinalExample object = obj; // 读对象引用
    int a = object.i; // 读普通域
    int b = object.j; // 读final域
  }
}

3.6.2 写final域的重排序规则

  • 上面的例子。线程A执行writer,线程B执行reader。
  1. JMM禁止编译器把final域的写重排序到构造函数之外。
  2. final域写之后,构造函数return之前,插入一个StoreStore屏障,禁止处理器把final域的写重排序到构造函数之外。
  • writer方法构造对象
  • 并且把引用赋值给obj。
  • 假设线程B读对象引用与读对象的成员域之间没有重排序。
  • 这里的问题就是普通写操作被重排序到了构造函数之外。导致线程B读取的普通域是一个初始化之前的数据。
  • 但是final域可以确保在对象引用对于任何线程可见之前,final域的数据一定是被正确初始化过的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-R9yFHEs4-1638673583744)(C:\Users\11914\AppData\Roaming\Typora\typora-user-images\image-20211204222711780.png)]

image-20211204222719960

3.6.3 读final域的重排序规则

  • 现在到线程B执行Reader()
    • 初次读取引用变量obj
    • 初次读取引用变量obj指向对象的普通域j
    • 初次读取引用变量obj指向对象的final域i。
  • 下面的意思是
    • 读普通变量i操作被重排序到了读对象引用之前。
    • 这个时候普通域i还没有被线程A写入。所以是一个错误的读取。
    • 但是final域的变量保证了,读引用对象一定是在final域之前的。

image-20211204223226422

总结

  • final域的写通过在final域变量后面添加一个StoreStore屏障保证了对象引用生效之前,final域变量已经赋值成功,也就是不会跑到构造函数之外。
  • final域的读操作,通过在读操作前面加上一个LoadLoad屏障,保证了读取final变量之前必须先读取对象引用。避免final域提前被读取。

3.6.4 final域为引用类型

  • final的引用对象的规则
    • 构造函数内的final对象成员域写入,必须是在构造函数外对被构造对象的引用赋值给引用变量之前。也就是不能重排序。
    • 比如说下面线程A执行writeOne,线程B 执行writeTwo,线程C执行reader方法。
    • 下面图的1final域的写入,2是对final域引用对象成员域的写入。3是被构造对象引用赋值给某个引用变量。这里除了1和3不能重排序之外,2和3也不能够重排序。
    • 也就是final保证了构造函数内的赋值是完整可见的。
public class FinalReferenceExample {
    
    
  final int[] intArray; // final是引用类型
  static FinalReferenceExample obj;
  public FinalReferenceExample () {
    
     // 构造函数
    intArray = new int[1]; // 1
    intArray[0] = 1; // 2
  }
  public static void writerOne () {
    
     // 写线程A执行
    obj = new FinalReferenceExample (); // 3
  }
  public static void writerTwo () {
    
     // 写线程B执行
    obj.intArray[0] = 2; // 4
  }
  public static void reader () {
    
     // 读线程C执行
    if (obj != null) {
    
     // 5
      int temp1 = obj.intArray[0]; // 6
    }
  }
}

image-20211204224216837

3.6.5 为什么final引用不能从构造函数内“溢出”

  • 也就是在构造函数final初始化之前,对象引用是不能够溢出。
    • 线程A执行writer,线程B执行reader。
    • 如果这里的操作2使得对象还没有完成构造之前对线程B可见。那么出现的问题就是B无法看到i的初始化。原因是这里的obj=this和i=1是有可能被重排序的。
  • 所以final的构造函数一定要执行完才能够把对象溢出。
public class FinalReferenceEscapeExample {
    
    
  final int i;
  static FinalReferenceEscapeExample obj;
  public FinalReferenceEscapeExample () {
    
    
    i = 1; // 1写final域
    obj = this; // 2 this引用在此"逸出"
  }
  public static void writer() {
    
    
    new FinalReferenceEscapeExample ();
  }
  public static void reader() {
    
    
    if (obj != null) {
    
     // 3
      int temp = obj.i; // 4
    }
  }
}

image-20211204224849220

3.6.6 final语义在处理器中的实现

  • 对于x86来说没有写写重排序,也没有读读重排序,所以final的屏障都被省略了。

3.6.7 JSR-133为什么要增强final的语义

  • 在旧的java内存模型里面线程可以看到final的变化,比如初始化之前的值。
  • 所以后面提出final增加写和读的重排序规则。只要对象正确构造,那么就不需要同步就能够保证final域在构造函数中被初始化的值。

3.7 happens-before

3.7.1 JMM的设计

  • JMM考虑的问题
    • 程序员希望JMM容易理解,所以需要基于一个强内存模型编写代码。
    • 编译器和处理器对内存模型的实现。内存模型尽量对编译器和处理器的束缚少一点。他们希望内存模型是一个弱内存模型。
    • 也就是要为程序员提供内存的可见性,也要对编译器和处理器的限制尽可能放松。

实现原理

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
  • 上面的关系是
    • ·A happens-before B。
    • ·B happens-before C。
    • ·A happens-before C。
  • 这里的第2和第3是必须完成的禁止重排序。但是第1条没有必要。所以把重排序分为两类
    • 会改变结果的重排序
    • 不会改变程序运行结果的重排序。
    • 对于会改变结果的重排序是禁止的。
    • 对于不会改变结果的,JMM对于编译器和处理器不作出要求。
  • 程序员可以根据提供的规则保证的内存可见性来编程,JMM屏蔽了规则的实现。

image-20211204225907179

  • 从上面看出
    • JMM向程序员提供的happen-before规则满足程序员的需求。提供了强大的内存可见性。
    • JMM对于编译器和处理器的束缚尽可能少。只要不改变结果,怎么做都可以。

3.7.2 happens-before的定义

  • 如果线程A的写操作a和线程B的读操作b存在happens-before,那么JMM可以保证a一定是对于b是可见的。
  • JSR-133的定义
    • 如果一个操作happens-before另一个操作,第一个操作对第二个操作的可见,并且第一个操作在第二个操作之前
    • 两个操作存在happens-before关系,并不意味着Java平台具体实现要按照这个顺序执行。如果重排序之后结果一致,那么这种重排序是可以的。
  • JMM的承诺
    • JMM对程序员承诺A happens-before B,也就是A的结果对B是可见的。A的执行顺序在B之前。
    • JMM对编译器和处理器的承诺,只要不改变结果就可以优化。
  • as-if-serial语义保证单线程内执行结果不被改变,happens-before关系保证同步的多线程执行结果不会改变。
  • ·as-if-serial语义给程序员创建了幻境,单线程程序按照程序顺序执行,happens-before的幻境就是正确的同步多线程是按照happens-before指定的顺序执行的。

3.7.3 happens-before规则

  1. 程序顺序规则
  2. 监视器锁规则
  3. volatile变量规则
  4. 传递性
  5. start()规则
  6. join()规则
  • 下面是volatile的happens-before分析
    • 1 happens-before 2和3 happens-before 4由程序顺序规则产生
    • 2->3是volatile规则产生的。读的时候必须能够看到对volatile最后的写入。
    • 1->4是传递性。这个传递性就是通过volatile的内存屏障和volatile的编译器重排规则实现的。

image-20211204231053333

  • 下面是start规则
    • 1->2是程序顺序规则
    • 2->4是start规则产生的
    • 1->4是传递性。

image-20211204231327884

  • join规则
    • 2->3程序顺序规则
    • 2->4是join规则
    • 4->5也是程序顺序规则
    • 2->5就是传递性了。

image-20211204231454151

3.8 双重检查锁定与延迟初始化

3.8.1 双重检查锁定的由来

  • 有时候需要推迟高开销的对象初始化操作,所以需要用到延迟初始化技术。
  • 下面例子的问题就是线程A执行1的同时线程B准备执行2。导致线程A可能看到instance还没有初始化。
public class UnsafeLazyInitialization {
    
    
  private static Instance instance;
  public static Instance getInstance() {
    
    
    if (instance == null) // 1:A线程执行
      instance = new Instance(); // 2:B线程执行
    return instance;
  }
}
  • 解决方案可以是synchronized
    • 但是问题是就是导致性能下降。因为不能被多个线程同时调用。
public class UnsafeLazyInitialization {
    
    
  private static Instance instance;
  public static Instance getInstance() {
    
    
    if (instance == null) // 1:A线程执行
      instance = new Instance(); // 2:B线程执行
    return instance;
  }
}

  • 后面又出现双重检查的方式。
    • 这样能够减少开销。
    • 问题是多个线程同时创建对象,加锁只能让一个线程创建
    • 创建好对象之后返回。但是对象有可能没有初始化。
public class DoubleCheckedLocking {
    
     // 1
  private static Instance instance; // 2
  public static Instance getInstance() {
    
     // 3
    if (instance == null) {
    
     // 4:第一次检查
      synchronized (DoubleCheckedLocking.class) {
    
     // 5:加锁
        if (instance == null) // 6:第二次检查
          instance = new Instance(); // 7:问题的根源出在这里
      } // 8
    } // 9
    return instance; // 10
  } // 11
}
  • 双重检查这里出现问题可能就是还没有初始化就能够返回对象引用。
  • 下面的给引用赋值的操作3和初始化操作2调换了。
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址

image-20211204232414571

image-20211204232426396

  • 问题的根源其实就是内存模型只是保证了A线程初始访问对象的时候一定是A2先于A4。但是没有保证另一个线程访问的时候,A2必须要在访问之前执行。出现问题的原因就是A2和A3重排序。导致A3提前给引用赋值,引用溢出被其它线程访问。
  • 那么如何防止这种情况?
    • 2和3不能够重排序。
    • 允许2和3重排序,但是不允许其它线程看见。

3.8.3 基于volatile的解决方案

  • 直接给对象加上volatile保证了执行的顺序。禁止了2和3的重排序。

3.8.4 基于类初始化的解决方案

  • JVM在类初始化期间获取锁,同步多个线程对类的初始化。
  • 如果两个线程并发执行getInstance那么就会阻塞一个线程。
  • 相当于就是重排序对其中一个线程不可见。
public class InstanceFactory {
    
    
  private static class InstanceHolder {
    
    
    public static Instance instance = new Instance();
  }
  public static Instance getInstance() {
    
    
    return InstanceHolder.instance ; // 这里将导致InstanceHolder类被初始化
  }
}

image-20211204233445659

  • 初始化一个类的规则

    • T是一个类,而且一个T类型的实例被创建
    • 静态方法被调用
    • 静态字段被赋值
    • 静态字段被使用
    • T是一个顶级类,而且断言语句嵌套在T内部执行。
  • 对于每一个类或者是接口都有一个初始化的锁LC对应。类初始化的时候JVM就会获取这个锁。所以在这里可以保证了只有一个线程能够初始化类的实例。

  • 类初始化的过程的5个阶段

    • 第一个阶段:通过Class对象上同步,控制类和接口的初始化。
      • 这里的图,A尝试获取锁也就是A1操作,B也尝试获取
      • A获取成功,接着执行A2,也就是看到对象未初始化,所以把线程状态改为initializing
      • A3操作就线程A释放锁。

    image-20211204233933410

  • 第二个阶段:线程A执行类的初始化,同时线程B在初始化锁的condition上等待。

    • A1执行类的静态初始化和静态字段的初始化
    • B1获取到初始化的锁
    • B2读取状态,发现正在初始化。
    • B3释放锁
    • B4在初始化锁的condition等待。

    • 第三个阶段:线程A设置state=intialized, 然后唤醒condition的等待的线程。

      image-20211204234551534

  • 第四个阶段:线程B结束类的初始化处理

    • B读取到了初始化锁
    • 读取状态
    • 释放初始化锁
    • 线程B的类初始化结束。

    image-20211204234713577

image-20211204234805006

  • 线程A在第二阶段初始化类,第三阶段释放锁,线程B在第四阶段获取锁,并且访问类。

  • happens-before保证线程A执行类的初始化的写入操作的时候,线程B可以看到。

  • 第五个阶段:线程C执行类的初始化的处理。

    • C获取锁
    • 读取到类的状态
    • 释放锁
    • 类处理初始化结束。

    image-20211204235030419

3.9 Java内存模型综述

3.9.1 处理器的内存模型

  • 顺序一致性内存模型是一个参考模型。
  • JMM和处理器内存模型都参照了顺序一致性内存模型。
  • 顺序一致性内存模型的问题是完全限制了编译器和处理器的发挥,影响性能。
  • 根据对不同的读写操作组合的执行顺序放松,常见的处理器内存模型有
    • 放松程序的读-写操作,产生了Total Store Ordering内存模型
    • 还有写-写产生了Partial Store Order内存模型。
    • 还有就是放松了读-写和读-读操作的顺序,产生了Relaxed Memory Order内存模型。
  • 不同类型的处理器内存模型与JMM的配合,JMM需要插入的内存屏障。

image-20211204235834883

3.9.2 各种内存模型之间的关系

  • JMM是一个语言级的内存模型。
  • 4种硬件级别的内存模型比JMM要弱。
  • JMM和硬件内存模型都比顺序一致性内存模型更弱。

3.9.3 JMM的内存可见性保证

  • 单线程程序,这个是不会产生内存可见性问题。
  • 正确同步多线程程序,程序的执行结果要与程序在顺序一致性模型的结果一样,这是JMM关注的点,JMM通过限制处理器和编译器的重排序来为程序员提供内存可见性保证
  • 未同步的多线程程序,JMM为他们提供最小的安全保障,执行得到的值一定是某个程序写入的值,或者是默认值。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Jf0s6T7y-1638673583755)(C:\Users\11914\AppData\Roaming\Typora\typora-user-images\image-20211205000447480.png)]

猜你喜欢

转载自blog.csdn.net/m0_46388866/article/details/121726752