Java并发编程的艺术

Java并发编程的艺术

第一章 并发编程的挑战

  • 目的:让程序运行更快

  • 面对挑战:上下文切换问题、死锁问题、硬件和软件资源限制

1.1 上下文切换

  • 时间片:CPU分配给各个线程的时间

因为时间片非常短,所以CPU通过快速切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后悔切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换

多线程一定快吗?

package chapter01;

/**
 * @description: 串行VS并发累加操作耗时,当累加次数不超过百万时,串行速度比并发快。原因:线程创建和上下文切换的开销
 * @author: WuDG/1490727316@qq.com
 * @date: 2021/3/12 10:16
 */
public class ConcurrencyTest {
    public static final long count = 1000000000L;
    public static void main(String[] args) throws InterruptedException {
        concurrency();
        serial();
    }

    private static void serial() {
        long start = System.currentTimeMillis();
        int result = add();
        int b = minus();
        long time = System.currentTimeMillis()-start;
        System.out.println("Serial:"+time+"ms,b="+b+",result="+result);
    }

    private static void concurrency() throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread thread = new Thread(() -> {
            add();
        });
        thread.start();
        int result = minus();
        long time = System.currentTimeMillis()-start;
        thread.join();
        System.out.println("Concurrency:"+time+"ms,result="+result);
    }
    public static int add(){
        int result = 0;
        for (long i = 0; i < count; i++) {
            result += 5;
        }
        return result;
    }
    public static int minus(){
        int result = 0;
        for (long i = 0; i < count; i++) {
            result--;
        }
        return result;
    }
}

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

  • Lmbench3
  • vmstat

如何减少上下文切换?

  • 无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据是,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模,不同的线程处理不同段的数据
  • CAS算法:Java的Atomic包使用CAS算法来更新数据,而不需要加锁
  • 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换

减少上下文切换实战

  • 使用

死锁

  • 结果:导致系统功能不可用
package chapter01;

/**
 * @description: 死锁案例
 * @author: WuDG/1490727316@qq.com
 * @date: 2021/3/12 10:50
 */
public class DeadLockDemo {
    public static String A = "A";
    public static String B = "B";
    public static void main(String[] args) {
        new DeadLockDemo().deadLock();
    }

    private void deadLock() {
        Thread thread1 = new Thread(() -> {
            synchronized (A) {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (B) {
                    System.out.println(Thread.currentThread().getName());
                }
            }
        }, "thread-a-b");

        Thread thread2 = new Thread(() -> {
            synchronized (B) {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (A) {
                    System.out.println(Thread.currentThread().getName());
                }
            }
        }, "thread-b-a");

        thread1.start();
        thread2.start();
    }
}

避免死锁方法:

  • 避免一个线程同时获取多个锁
  • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
  • 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制
  • 对于数据库锁,加锁和解锁必须在同一个数据库连接里,否则会出现解锁失败的情况

1.3 资源限制的挑战

  • 什么是资源限制:指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。硬件资源有带宽上传/下载速度、硬盘读写速度和CPU的处理速度。软件资源有数据库的连接数和socket连接数等
  • 资源限制引发的问题:执行效率低
  • 如何解决资源限制问题:对于硬件资源限制,可以考虑使用集群并行执行程序。对于软件资源限制,可以考虑使用资源池将资源复用,如数据库连接池和Socket连接复用
  • 在资源限制情况下进行并发编程:根据不同的资源限制调整程序的并发度

1.4 本章小结

介绍了在进行并发编程时可以遇到的几个调整,并给出了一些解决建议

多使用JDK包提供的并发容器和工具类

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

Java源码 --> 编译 --> Java字节码 --> 类加载器 --> JVM ---> 汇编指令 --> CPU执行

Java所使用的并发机制依赖于JVM的实现和CPU的指令

volatile的应用

volatile是轻量级的synchronized,在多处理器开发中保证了共享变量的“可见性”

可见性:当一个线程修改了一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用的恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度

volatile的定义与实现原理

Java语言规范第三版中对volatile的定义:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的

CPU术语定义

术语单词描述
内存屏障memory barriers是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行cache line缓存中可以分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期
原子操作atomic operations不可中断的一个或一系列操作
缓存行填充cache line fill当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或所有)
缓存命中cache hit如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取
写命中write hit当处理器将从操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存航,则处理器将这个操作数写回到缓存,而不是写回到内存
写缺失write misses the cache一个有效的缓存行被邪恶如到不存在的内存区域

volatile如何保证可见性的?

被volatile修饰的变量,编译成汇编代码后,写操作指令会多出 lock指令,lock指令的作用如下:

  • 将当前处理器缓存行的数据写回到系统内存
  • 这个写会内存的操作会使其他CPU里缓存里该内存地址的数据无效

2.2 synchronized的实现原理与应用

元老角色,很多人会称呼它为重量级锁。

随着Java SE 1.6对synchronized进行了各种优化后,并不那么重了

为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程

三种表现形式:

  • 普通实例同步方法:锁是当前实例对象
  • 静态同步方法:锁是当前类的Class对象
  • 同步代码块:锁是synchronized括号里配置的对象

当一个线程视图访问同步代码块时,首先必须得到锁,退出或抛出异常时必须释放锁

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但二者的实现戏剧节不一样。

代码块同步是使用monitorenter和monitorexit指令实现的,而方法通过不是使用另一种方式实现

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit配对。任何对象都有一个monitor与之对应,当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁

Java对象头

synchronized用的锁是存在Java对象头里的。如果对象时数组类型,虚拟机就用3个字宽存储对象头,如果对象时非数组类型,则用2字宽存储对象头。在32为虚拟机中,1字宽=4字节,即32bit

Java对象头的长度

image-20210312121426928

Java对象头的Mark里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下图:

image-20210312121615182

在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储一下4种数据:

image-20210312121713055

在64位虚拟机下,Mark Word是64位大小的,存储结构如下:

image-20210312121849794

锁的升级与对比

Java Se 1.6为了减少获得锁和释放锁带来的性能消耗,引入“偏向锁”和“轻量级锁”

Java Se 1.6中,锁一种有4种状态,级别从低到高:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率

1. 偏向锁

大多数情况下,锁不仅不存在多线程竞争,反而总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,怎小时线程已经或得了锁,如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置为1(标识当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

  • 偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁活着标记对象不适合作为偏向锁,最后唤醒暂停的线程

    image-20210312131919843

  • 关闭偏向锁:偏向锁在Java 6和Java 7是默认启用的,但是在应用程序启动几秒钟之后才激活,可以通过JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。也可以直接管理偏向锁:-XX:-UseBiasedLocking=false。name程序会默认进入轻量级锁状态

2. 轻量级锁

  • 轻量级锁加锁:线程在执行同步代码块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Workd复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁
  • 轻量级锁解锁:轻量级锁解锁时,会使用原子的CAS操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下面是两个线程同时竞争锁,导致锁膨胀的流程图

image-20210312133144710

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于重量级锁状态下时,其他线程视图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争

下图出自博客

3. 锁的优缺点对比

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级别的差距如果线程间存在竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU追求响应时间同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量同步块执行速度较长

2.3 原子操作的实现原理

不可被中断的一个或一系列操作

1. 术语定义

名称英文解释
缓存行cache line缓存的最小操作单位
比较并交换compare and swapCAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换
CPU 流水线CPU pipeline指令执行
内存顺序冲突memory order violation内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线

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

基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作

处理器保证从系统内存中读取或者写入一个字节是原子的,即当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址

通过总线锁保证原子性

如果多个处理器同时对共享变量进行读改写操作,那么多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值回合期望的不一致,如下面这个例子

image-20210312135832268

原因:多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存中,要想保住读改写共享变量的操作时原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存

处理器使用总线锁就是来解决这个问题的。总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存

使用缓存锁保证原子性

同一时刻,只需保证对某个内存地址的操作时原子性即可

处理器L1,L2,L3高速缓存

处理器不在总线上发出LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一直执行机制会阻止同时修改两个以上处理器缓存的内存区域,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效

下面两种情况下处理器不会使用缓存锁定

  • 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定
  • 有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定

3. Java如何实现原子操作

Java中科院通过锁和循环CAS的方式来实现原子操作

  1. 使用缓存CAS实现原子操作

JVM中CAS操作就是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止

package chapter01;

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

/**
 * @description: 基于CAS线程安全的计数器方法safeCount和一个非线程安全的计数器count
 * @author: WuDG/1490727316@qq.com
 * @date: 2021/3/12 14:20
 */
public class CasCount {
    private AtomicInteger atomicI = new AtomicInteger(0);
    private int i = 0;
    public static void main(String[] args) {
        final CasCount cas = new CasCount();

        List<Thread> ts = new ArrayList<>();
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100; i++) {
            Thread thread = new Thread(() -> {
                for (int i1 = 0; i1 < 100000; i1++) {
                    cas.count();
                    cas.safeCount();
                }
            });
            ts.add(thread);
        }
        for (Thread t : ts) {
            t.start();
        }
        // 等待所有线程执行完成
        for (Thread t : ts) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(cas.i);
        System.out.println(cas.atomicI.get());
        System.out.println(System.currentTimeMillis()-start);

    }

    private void safeCount() {
        for (;;){
            int i = atomicI.get();
            boolean b = atomicI.compareAndSet(i, ++i);
            if(b){
                break;
            }
        }
    }

    private void count() {
        i++;
    }
}
  1. CAS实现原子操作的三大问题
    1. ABA问题:如果一个值原来是A,变成了B,又变成了A,CAS进行检查是发现它的值并没有发生变化,但实际上却变化了。ABA问题的解决思路就是使用版本号
    2. 循环时间长开销大:自旋CAS如果长时间不成功会给CPU带来非常大的执行开销
    3. 只能保证一个共享变量的原子操作:用锁,或者把多个共享变量合并成一个共享变量来操作
  2. 使用锁机制来实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁

2.4 本章小结

研究了volatile、synchronized和原子操作的实现原理

第三章 Java内存模型

可见性问题,内存模型基本概念,Java内存模型中顺序一致性,重排序与顺序一致性内存模型

3.1 Java内存模型基础

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

  • 线程之间如何通信
  • 线程之间如何同步

通信是指线程之间以何种机制来交换信息

在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态机芯隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式通信

Java的并发采用共享内存模型,Java线程之间的通信总是隐式进行。

2. Java内存模型的抽象结构

Java中,所有实例域、静态域、数组元素都存储在堆内存中,对内存在线程之间共享。

JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象角度来看,JMM定义了线程和主内存之间的抽象关系。线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存。本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。涵盖了缓存、写缓冲区、寄存器记忆其他的硬件和编译器优化

Java内存模型的抽象结构示意图

image-20210312144953935

A,B线程之间要通信,必须经历下面2个步骤

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中去
  2. 线程B到主内存中去读取线程A之间已更新过的共享变量

线程之间的通信图

image-20210312145136963

JMM通过控制主内存与每个线程的本地内尺寸之间的交互,来为Java程序员提供内存可见性保证

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

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型:

  1. 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  2. 指令级并行的重排序:现代处理器采用了指令级并行技术来讲多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  3. 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序中执行

image-20210312145738083

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序

4. 并发编程模型的分类

image-20210312150958482

image-20210312151010767

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类

image-20210312151307043

5. happens-before简介

从JDK 5开始,Java使用心得JSR-133内存模型

JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作直接必须存在happens-before关系

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
  • 监视器锁规则:对一个锁的解锁,happens-before于随后随这个锁的加锁
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个域的读
  • 传递性:如果A happens-before B,且 B happens-before C,则 A happens-before C

两个操作直接具有happens-before关系,并不意味着前一个操作必须在后一个操作直接执行!happens-before仅仅要求前一个操作(执行结果)对后一个操作可见,且前一个操作按顺序排在第二个操作直接

happens-before与JMM的关系图如下

image-20210312152252947

3.2 重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段

1. 数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

image-20210312152446712

上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变

2. as-if-serial语义

语义:不管怎么重排序(编译器和处理器为了提高并行度),程序的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义

double pi = 3.14;    // A
double r = 1.0;      // B
double area = pi * r * r;    // C

上面3个操作的数据依赖关系如图:

image-20210312153308640

最终重排序后结果可能:

image-20210312153352156

3. 程序顺序规则

根据happens-before的程序顺序规则,上面计算圆面积的代码存在3个happens-before关系

  1. A happens-before B
  2. B happens-before C
  3. A happens-before C(传递性)

JMM仅仅要求前一个操作(执行结果)对后一操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A的执行结果不需要对操作B可见。而且重排序操作Ahead操作B后的执行结果与按happens-before顺序执行一致。在这种情况下,JMM会认为这种重排序并不非法,JMM允许这种重排序

4. 重排序对多线程的影响

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

假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法,线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入?

答案:不一定

1 和 2重排序

image-20210312155817431

3 和 4 重排序

image-20210312155833691

在程序中,操作3和操作4存在控制依赖关系。

编译器和处理器采用猜测执行来克服控制相关性并行度的影响。

执行线程B的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中

重排序在这里破坏了多线程程序的语义

3.3 顺序一致性

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型为参考

1. 数据竞争与顺序一致性

当程序未正确同步时,就可能存在数据竞争。Java内存模型规范对数据竞争的定义:

  • 在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序

如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序

如果程序是正确同步的,程序的执行将具有顺序一致性-即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同

2. 顺序一致性内存模型

是一个被计算机科学家理想化的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  1. 一个线程中的所有操作必须按照顺序的顺序来执行
  2. 所有的线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见

image-20210312162730928

3. 同步程序的顺序一致性效果

class ReorderExample {
    int a = 0;
    boolean flag = false;
    public synchronized void writer() {
        a = 1;    // 1
        flag = true;    // 2
    }
    public synchronized void reader() {
        if(flag){    // 3
            int i = a * a;    // 4 
        }
    }
}

image-20210312163322743

4. 未同步程序的执行特征

3.4 volatile的内存语义

1. volatile的特性

class VolatileFeatureExample {
    volatile long v1 = 0L;    // 使用volatile声明64位的long型变量
    public void set(long l) {    
        v1 = l;		// 单个volatile声明的写
    }
    public void getAndIncrement() {
        v1++;    // 复合(多个)volatile变量的读/写
    }
    public long get(){
        return v1;    // 单个volatile变量的读
    }
}

假设有多个线程分别调用上面程序3个方法,则中国程序在语义上和下面程序等价

class VolatileFeatureExample {
    long v1 = 0L;    //64位的long型普通变量
    public synchronized void set(long l) {    // 对单个普通变量的写用一个锁同步
        v1 = l;
    }
    public void getAndIncrement() {    // 普通方法调用
        long temp = get();    // 调用已同步的读方法
		temp += 1L;    // 普通写操作
        set(temp);    // 调用已同步的写方法
    }
    public synchronized long get(){    // 对单个普通变量的读用同一个锁同步
        return v1;
    }
}

锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性

  • 可见性。对一个volatile变量的读,总是能够看到(任意线程)对这个volatile变量最后的写入
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种操作不具有原子性

2. volatile写-读建立的happens-before关系

从JSR-133开始(JDK 5),volatile变量的写-读可以实现线程之间的通信

从内存语义的角度来说: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()方法。根据happens-before规则,这个过程建立的happens-before关系可以分为3类:

  1. 根据程序次序规则:1 h-b 2, 3 h-b 4
  2. 根据volatile规则: 2 h-b 3
  3. 根据传递性规则: 1 h-b 4

image-20210312165438834

3. volatile写-读内存语义

volatile写的内存语义:

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存

image-20210312165636854

volatile读内存语义:

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

volatile读-写内存语义总结

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

image-20210312170307053

4. volatile内存语义的实现

重排序:编译器重排序和处理器重排序

JMM会分别限制这两种类型的重排序类型

image-20210312170635029

5. JSR-133为什么增加volatile的内存语义

由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性

3.5 锁的内存语义

锁可以让临界区互斥执行

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

锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息

下面是锁是否-获取的实例代码:

class MonitorExample {
    int a = 0;
    public synchronized void writer() {  // 1
        a++;  // 2
    }    // 3
    public synchronized void reader() {  // 4
        int i = a;  // 5
    }  // 6
}
# java  并发 

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×