JUC读书笔记(一)

死锁产生的原因

  • 互斥:某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。
  • 占用且等待:一个进程本身占有资源(一种或多种),同时还有资源未得到满足,正在等待其它进程释放该资源。
  • 不可抢占:别人已经占有了某项资源,你不能因为自己也需要该资源,就去把别人的资源抢过来。
  • 循环等待:存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源。

避免死锁的四个常用方法

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

Volatile

Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

Volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
Volatile不会引起线程上下文的切换和调度。

Volatile实现原理

Lock前缀的指令在多核处理器下会引发两件事情:

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

如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存,但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证每个处理器上的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器内存中。

Volatile的两条实现原则

  1. Lock前缀指令会引起处理器缓存回写到内存。
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。

Synchronized

synchronized实现同步的基础(同步锁,锁的到底是谁?)

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

原子操作的实现原理

CPU术语定义
CAS(Compare and Swap):CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变换才交换成新值,发生了变化则不交换。

处理器如何实现原子操作

  1. 使用总线锁保证原子性:使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
  2. 使用缓存锁保证原子性:内存区域如果被缓存在处理器的缓存行中,并且在LOCK操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。

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

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

Java如何实现原子操作

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

CAS实现原子操作的三大问题:

  1. ABA问题

因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化就更新,但是如果一个值A变成了B又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号(version),变量前追加版本号,每次更新的时候就把版本号加1,如果变量和版本号的值都等于预期中的值才进行CAS操作。

  1. 循环时间长开销大

自旋CAS如果长时间不成功,会给CPU带来很大的执行开销。

  1. 只能保证一个共享变量的原子操作

只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对于多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以使用锁。或者可以把多个共享变量合并成一个共享变量来进行操作(AtomicReference类保证了引用对象之间的原子性)

Java内存模型

JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

内存屏障类型表

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1的数据的装载先于Load2及所有后续装载指令的装载
StoreStore Barriers Store1;StoreStore;Store2 确保Store1的数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
LoadStore Barriers Load1;LoadStore;Store2 确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存
StoreLoad Barriers Store1;StoreLoad;Load2 确保Store1数据对其他处理器变得可见(指刷新到内存)先于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行屏障之后的内存访问指令。

happens-before规则

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

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(The first is visible to and ordered before the second)。编译器和处理器的重排序问题,happens-before是对程序员的一种保证或心理安慰,毕竟大多数人也不在乎程序到底有没有被重排序(只要结果与预期结果一致即可)

数据依赖性

如果两个操作访问同一个变量,且这两个操作其中一个操作为写操作,此时这两个操作之间就会存在数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序(因为改变执行顺序,程序的执行结果就会被改变),且不同处理器和不同线程之间的数据依赖性不被编译器和处理器考虑(仅限于单线程执行的操作或单处理器的指令序列)

as-if-serial 语义

不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。

顺序一致性内存模型

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

  1. 一个线程中的所有操作都必须按照程序的顺序来执行。
  2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且理科对所有线程可见。

JMM不对此特性进行保证,JMM在具体实现上的基本方针依旧为:在不改变(正确同步)的程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Nu’ll,False),JMM保证线程读操作读取到的值不会无中生有。

为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。

JMM不保证未同步程序的执行结果与改程序在顺序一致性模型中的执行结果一致。(此举会妨碍大量的处理器和编译器的优化,极大程度地影响程序的执行性能,并且保证执行结果一致并没有什么意义)

未同步程序在两个模型的执行特性有如下几个差异

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行。
  • 顺序一致性模型保证所有的线程都只能看到一致的操作执行顺序,而JMM不保证线程能看到一致的操作执行顺序。
  • JMM不草正对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读、写操作都具有原子性。

Volatile的内存语义

Volatile的特性

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

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

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

重排序规则

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

基于保守策略的JMM内存屏障插入策略

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的前面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

各屏障的作用

  • StoreStore屏障:保障上面所有的普通写在volatile写之前刷新到主内存
  • StoreLoad屏障:避免volatile写与后面可能有的volatile读/写操作重排序。
  • LoadLoad屏障:禁止处理器把上面的volatile读与下面的普通读重排序。
  • LoadStore屏障:禁止处理器把上面的volatile读与下面的普通写重排序。

实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
可看出JMM的实现特点:首先保证正确性,然后再去追求执行效率。

JSR-133中增强了volatile的内存语义:严格限制编译器和处理器对volatile变量和普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。

volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。

锁的内存语义

锁释放的内存语义
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
锁获取的内存语义
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
内存语义总结

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
  • 线程B获取一个锁,实质上是线程B接受了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

锁释放和volatile写的内存语义相同,锁获取和volatile读的内存语义相同

(锁内存语义的实现暂时略去)

final域的内存语义

final域的重排序规则

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

写final域的重排序规则

  1. JMM禁止编译器把final域的写重排序到构造函数之外。
  2. 编译器会在final域的写入之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
  3. 对于引用类型:写final域的重排序规则对编译器和处理器增加了下述约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外吧这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

确保对象引用为任意线程可见之前,对象的final域已经被正确的的初始化过了,而普通域不具有这个保障。

读final域的重排序规则

  • 初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(仅针对处理器)。编译器会在读final域操作之前插入一个LoadLoad屏障。

确保在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能再构造对象中“逸出”。即在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。

双重检查锁定存在的问题与解决方案

懒汉式单例模式:

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

进行了同步处理的懒汉式单例模式:

public class SafeLazyInitialization {
    
    
    private static Instance instance;

    public synchronized static Instance getInstance(){
    
    
        if (instance == null){
    
    
            instance = new Instance();
        }
        return instance;
    }

}

特点:

  • 如果getInstance()方法被多个线程频繁地调用,将会导致程序执行性能的下降。
  • 如果getInstance()方法不会被多个线程频繁地调用,那么这个延迟初始化方案将能提供令人满意的性能。

由于早期的JVM中,synchronized(甚至是无竞争的synchronized)存在巨大的性能开销,于是人们想到使用双重检查锁定的单例模式来降低同步的开销:

public class DoubleCheckedLocking {
    
    
    private static Instance instance;

    public static Instance getInstance(){
    
    
        if (instance == null) {
    
    
            synchronized (DoubleCheckedLocking.class) {
    
    
                if (instance == null){
    
    
                    instance = new Instance();
                }
            }
        }
        return instance;
    }

}

特点:

  • 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
  • 在对象创建好之后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象。
  • 该代码其实是一个错误的优化,有可能读取到instance不为null时,instance引用的对象还没有完成初始化。

问题的根源
创建一个对象其实可以分解为三个步骤:

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

实际上步骤2和3有可能会被编译器重排序(因为该重排序并不会影响单线程程序执行的结果并且可以提高程序的执行性能),但多线程情况下可能访问到的不为null的对象实际上尚未初始化。

实现线程安全的延迟初始化的两个方法

  1. 不允许2和3重排序
  2. 允许2和3重排序,但不允许其他线程“看到”这个重排序。

基于volatile的解决方案

public class SafeDoubleCheckedLocking {
    
    
    private volatile static Instance instance;

    public static Instance getInstance(){
    
    
        if (instance == null) {
    
    
            synchronized (SafeDoubleCheckedLocking.class) {
    
    
                if (instance == null){
    
    
                    instance = new Instance(); //现在的instance为volatile,所以没问题了
                }
            }
        }
        return instance;
    }

}

对象的引用声明为volatile后,2和3之间的重排序将在多线程环境之中被禁止,即本方案本质上是通过禁止2和3之间的重排序来保证线程安全的延迟初始化的。

基于类初始化的解决方案

public class InstanceFactory {
    
    
    private static class InstanceHolder{
    
    
        public static Instance instance = new Instance();
    }
    public static Instance getInstance(){
    
    
        return InstanceHolder.instance;//此处将导致InstanceHolder类被初始化
    }
}

对比与评价:

  • 基于类初始化的方案的实现代码更为简洁。
  • 基于volatile的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化之外,还可以对实例字段实现延迟初始化。
  • 需要对实例字段使用线程安全的延迟初始化,请使用基于volatile的延迟初始化方案。
  • 需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的延迟初始化方案。
  • 但要记住,大多数情况下,正常初始化要由于延迟初始化。字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。

JMM的内存可见性保证

按程序类型,Java程序的内存可见性保证可以分为下列3类:

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同保证单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  • 未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值。

JSR-133对旧内存模型的修补

JSR-133对JDK5之前的内存模型的修补主要有两个。

  • 增强volatile的内存语义。旧内存模型允许volatile变量与普通变量重排序。JSR-133严格限制volatile变量与普通变量的重排序,使volatile的写-读和锁的释放-获取具有相同的内存语义。
  • 增强final的内存语义。在旧的内存模型中,多次读取同一个final变量的值可能会不相同。为此,JSR-133为final增加了两个重排序规则。在保证final引用不会从构造函数内逸出的情况下,final具有了初始化安全性。

猜你喜欢

转载自blog.csdn.net/qq_42759832/article/details/115426979