Java并发编程艺术读后感

第一章:并发编程的挑战

1、即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。

我们知道,一个线程在一个时刻只能运行在一个处理器核心上,那么单核处理器支持多线程的意义在哪?

因为线程任务可以分为IO密集型或CPU密集型,当线程任务需要IO操作时,可以把CPU让出给需要计算逻辑的线程。

所以即使是单核处理器执行多线程任务时,也有可能比单线程快。另外,单核处理器不会出现多核处理器中多线程出现的问题。

2、多核处理器执行多线程任务就一定比单线程快吗?

答案同样是不一定,我们知道CPU是通过给每个线程分配时间片来实现多线程的,当任务执行一个时间片后就会切换下一个任务。

但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态,这个过程叫做上下文切换。

毫无疑问,上下文切换会耗费许多时间,所以当另起的线程的上下文切换的时间比其省去的计算时间多时,多线程就更慢。

3、我们再来思考一个问题,当线程数小于CPU核心数时,多线程是否一定比单线程快?

答案同样是不一定,该问题的核心在于即使线程数小于CPU核心数,线程仍然会发送上下文切换。

因为多线程的本质是用时间片实现的,每个线程的时间片用完后都会发生上下文切换,即使有多余的CPU资源。

4、减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。

使用最少线程和协程很好理解,使用无锁并发编程为啥能减少上下文切换次数呢?

因为多线程竞争锁时,如果线程没有获取到锁,就会让出时间片给其他线程,从而引起上下文切换。

同理,使用CAS更新数据也是为了避免加锁。

5、上下文切换监测工具有Lmbench3、vmstat。

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

在了解并发机制前,我们先来了解下图(CPU内存模型):

 

书中有这么一段话,为了提高处理速度,处理器并不直接和内存通信,而是先将系统内存数据读到内部缓存(L1,L2或其他),

但是操作完不知道何时会写到内存,各个处理器保存中原数据的副本,很有可能不一样,这就是并发的根本问题:缓存不一致。

所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议:

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是否过期了,当处理器发现自己缓存行对应的内存地址被修改,

就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作时,会重新从系统内存中把数据读到处理器缓存里。

 1、volatile可见性的实现原理

有volatile修饰的共享变量进行写操作时,jvm就会向处理器发送一条Lock前缀指令,该操作会引起处理器缓存回写到内存,

另外,一个处理器的缓存回写到内存会导致其他处理器的缓存无效,需要从内存读取数据。

2、synchronized实现原理与应用

Java中每一个对象都可以作为锁。

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

从JVM规范中可以看到synchronized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步。

代码块同步是使用Monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的。

3、什么是锁,锁存在哪里?

synchronized用的锁是存在Java对象头里的,名叫Mark Work,具体结构可参阅书籍。

4、锁的四种状态及转换:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态

锁的级别会随着竞争而升级,但是不会降级,目的都是为了提高获取锁和释放锁的效率。

5、锁的优缺点对比

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

追求响应时间,同步执行速度非常快

重量级锁      线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量、同步块执行速度较快

6、Java如何实现原子操作

在Java中可以通过锁和循环CAS的方式来实现原子操作,JVM中的CAS利用了处理器提高的CMPXCHG指令实现的。

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

Ⅰ、ABA问题:假如一个数据原来是A,变成了B,又变成了A。

ABA问题的解决思路是使用版本号,每次变量更新时把版本号加一。

从Java1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReferenc来解决ABA问题,解决思路就是使用版本号。

Ⅱ、循环时间长开销大。

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提高的pause指令,那么效率会有一定提升。

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

从Java1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里进行CAS操作。

8、除了偏向锁,JVM实现锁的方式都用了循环CAS

即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当他退出同步块时使用循环CAS释放锁。

第三章:Java内存模型

1、线程之间如何通信以及线程之间如何同步?

通信是指线程之间以何种机制来交换消息,在命令式编程中,线程之间通信方式有两种:共享内存和消息传递。

同步是指程序中用于控制线程间操作发生相对顺序的机制。

在共享内存模型中,通信是隐式的,同步时显式的。共享内存模型是指线程间通过写-读内存中的公共状态来通信或同步。

在消息传递模型中,通信是显示的,同步是隐式的。消息传递模型是指线程间通过发送消息来通信或同步。

Java的并发采用的是共享内存模型。

2、Java内存模型

同CPU内存模型一样,Java也有自己的内存模型。

Ⅰ、CPU内存模型

Ⅱ、Java内存模型

在Java中,所有的实例域、静态域和数组元素都存储在堆内存中,而堆内存在线程之间是可以共享的(共享变量)。

局部变量、方法定义参数和异常处理器参数存储在栈中,不会再线程之间共享,它们不会存在内存可见性的问题。

Java线程之间的通信由Java内存模型(简称JMM)控制,JMM决定一个线程堆共享变量的写入何时对另一个线程可见。

3、volatile重排序

在执行程序时,为了提供性能,编译器和处理器常常会对指令做重排序。

通常为了遵循as-if-serial语义,编译器和处理器都不会对存在数据依赖关系(控制依赖除外)的操作做重排序。

as-if-serial语义是指:不管怎么重排序,(单线程)程序的执行结果不能被改变。

重排序分三种类型。

Ⅰ、编译器优化重排序:编译器在不改变单线程程序语义的情况下,可以重新安排语句的执行顺序。

Ⅱ、指令级并行重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

Ⅲ、内存系统重排序:因为处理器使用缓存读写缓冲区,所以加载和存储有可能乱序执行。

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

内存屏障类型表(Load是读,Store是写)
屏障类型 指令示例 说明
LoadLoad             Load1;LoadLoad;Load2             确保Load1数据的装载先于Load2及所有后续装置指令的装载。
StoreStore           Store1;StoreStore;Store2         确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储。
LoadStore            Load;LoadStore;Store               确保Load数据装载先于Store及所有后续存储指令刷新到内存。

StoreLoad           

Store;StoreLoad;Load              

确保Store数据对其他处理器可见(刷新到内存)先于Load及所有后续装载指令的装载。

StoreLoad会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,

才执行该屏障之后的内存访问指令(存储和装载指令)。

StoreLoad是全能型屏障,不仅仅只是确保Store数据对其他处理器可见(刷新到内存)先于Load及后续装载指令的装载。

因为它还会使该屏障之前的所有内存访问指令包括读和写等完成之后,才执行该屏障之后的内存访问指令

而由于是访问内存,所以共享变量需要是volatile修饰

另外值得注意的是,刷新内存并非只刷新volatile变量,而是该线程对应的本地内存中所有的共享变量。

 接下来我们看看JMM针对编译器制定的重排序规则:

是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写 YES YES NO
volatile读 NO NO NO
volatile写 YES NO NO

为了实现JMM针对编译器制定的重排序规则能应用于处理器,编译器会在指令序列中插入内存屏障来禁止处理器重排序(似乎不完整)。

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

仔细分析后可以看到当第一个操作时普通读,第二个操作时普通写时,StoreStore屏障并不能禁止它们之间的重排序。

另外我们对内存屏障的理解最好还是基于内存屏障类型表,感觉指令序列示意图上对内存屏障指令的解释缺了点什么。

4、未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:

线程执行时读取到的值要么是之前某个线程写入的值,要么是默认值(0,Null,False)。

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

另外,JMM不保证对64位的long型和double型变量的写操作具有原子性。

因为在32位的机器上保证对64位数据的写操作具有原子性,会有比较大的开销。

为了照顾这种处理器,Java语言鼓励但是不强求JVM对64位的long型变量和double型变量的写操作具有原子性。

当JVM在这种机器上运行时,可能会把对64位long/double的写操作拆为两个32位的写操作来执行。

在JSR-133之前旧的内存模型中,一个64位long/double型变量的读/写操作可以拆分为两个32位的读/写操作来执行。

从JSR-133内存模型开始(JDK5开始),仅仅允许把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。

5、volatile写-读的内存语义

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

6、内存屏障解决了重排序导致的什么问题?举个例子(待写)

 我们在第二章知道了内存语义的实现原理,它成功解决了CPU缓存不一致的问题。那么内存屏障是怎么解决多线程下重排序的问题呢?

正向分析有点难,我们通过编译器制定的重排序规则来反向分析,先来总结一下:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。
  • 当第一个操作是volatile写时,第二个操作是volatile读时,不能重排序。

现在我们假设使用的是一个volatile变量,它读取的都是最新的数据,在重排序的情况下会引发什么问题:

 7、锁的内存语义

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

以ReentrantLock为例,它的实现依赖于Java同步框架AbstractQueuedSynchronizer(简称AQS)。

AQS使用一个整型的volatile变量(命名为state)来维护同步状态,这个volatile变量是ReentrantLock内存语义实现的关键。

 

ReentantLock分为公平锁和非公平锁,另外ReentrantLock是可重入锁。

这里仅对概念讲解一下:

公平锁:谁等待的时间最长谁优先获得锁,也就是符合FIFO原则。

非公平锁:谁先抢到锁,锁就是谁的。通常来说,刚释放锁的线程更有机会再次获得锁。

可重入锁:任意线程在获取到锁之后(没有释放锁)能够再次获取该锁而不会被锁所阻塞,俄罗斯套娃。

8、final域重排序

对于final域,编译器和处理器要遵守两个重排序规则。(具体示例可以参阅书籍)

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

Ⅰ、写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则包含以下两个方面:

JMM禁止编译器把final域的写重排序到构造函数之外。

编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障,从而禁止处理器把final域的写重排序到构造函数之外。

Ⅱ、读final的重排序规则

读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。

(注意这个规则仅针对处理器),编译器会在读final域操作的前面插入插入一个LoadLoad屏障。

因为初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。

由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,该规则就是针对少数处理器的。

9、concurrent包的实现

由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信现在有了下面四种方式。

  • A线程写volatile变量,随后B线程读这个volatile变量。
  • A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
  • A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
  • A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量

Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子的方式对内存执行读-改-写操作。

这是多处理器中实现同步的关键。同时,volatile变量的读/写和CAS可以实现线程之间的通信。

把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。

如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。

首先,声明共享变量为volatile。

然后,使用CAS原子条件更新来实现线程之间的同步。

同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间等待通信。

10、双重检查锁定与延迟优化

在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。

以单例模式为例:

public class Singleton {

    private volatile static Singleton instance;
    
    public static Singleton getInstance() {
        if(instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

现在想想为什么会需要volatile关键字?

在代码instance = new Singleton()处其实可以分为三行代码:

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

//重排序顺序
memory = allocate();
instance = memory;
ctorInstance(memory);

如果发生重排序后,那么其他线程读到的单例将会是还没有初始的对象。

另外单例模式还有另外一种写法:

JVM在类的初始化阶段(即在Class被加载后,且被线程使用前),会执行类的初始化。

在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。

根据Java语言规范,在首次发生下列任意一种情况时,一个类型为T的类或接口将被立即初始化。

  • T是一个类,而且一个T类型的实例被创建。
  • T是一个类,且T中声明的一个静态方法被调用。
  • T中声明的一个静态字段被赋值。
  • T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
  • T是一个顶级类,而且一个断言语句嵌套在T内部被执行。
public class Singleton {

    private static final Singleton instance = new Singleton();
        
    public static Singleton getInstance() {
        return instance;
    }
}

如果我们要获取Singleton,可以通过Singleton.getInstance()的方式,这样我们将符合第二条规范。

public class InstanceFactory {

    private static class InstanceHolder{
        public static Instance instance = new Instance();
    }
public static Instance getInstance(){ return InstanceHolder.instance; } }

这种单例模式符合第四条规范,因为访问的实例是Instance类,所以不符合第一条规范。

同样,由于是读访问instance实例,所以不符合第三条规范。

 第四章:Java并发编程基础

1、为什么IO密集型线程优先级需要高于CPU密集型线程?

原文中说到:

优先级高的线程分配时间片的数量要多于优先级低的线程。设置线程优先级时:

针对频繁阻塞(休眠或者I/O操作)的线程需要设置较高优先级,

而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。

这段话应该怎么理解呢,可参考 IOS 如何高效的使用多线程

其在线程优先级权衡中提到:通常来说,线程调度除了轮转法以外,还有优先级调度的方案。

在线程调度时,高优先级的线程大概率会更早的执行。有两个概念需要明确:

  • IO 密集型线程:频繁等待的线程,等待的时候会让出时间片。

  • CPU 密集型线程:很少等待的线程,意味着长时间占用着 CPU。

在特殊场景下,当多个 CPU 密集型线程霸占了所有 CPU 资源,

而它们的优先级都比较高,而此时优先级较低的 IO 密集型线程将持续等待,产生线程饿死的现象。

为避免线程饿死,系统会逐步提高被“冷落”线程的优先级,IO 密集型线程通常下比 CPU 密集型线程更容易获取到优先级提升。

虽然系统会自动做这些事情,但是这总归会造成时间等待,可能会影响用户体验。所以开发者需要从两个方面权衡优先级问题:

  • 让 IO 密集型线程优先级高于 CPU 密集型线程。
  • 让紧急的任务拥有更高的优先级。

2、线程间的状态及转换

 

Java线程在运行的生命周期中可能处于上图所示的六种不同状态,在给定时刻,线程只能处于其中的一个状态。

 状态名称  说明
 NEW  初始状态,线程被构建,但是还没有调用start方法
 RUNNABLE  运行状态,Java线程将操作系统中的就绪和运行状态统称为运行中
 BLOCKED  阻塞状态,表示线程阻塞于锁
 WAITING  等待状态,进入该状态标识当前线程需要等待其他线程做出一些动作(通知或中断)
 TIME_WAITING  超时等待状态,与WAITING不同的是,在等待指定时间后会自行返回
 TERMINATED  终止状态,表示当前线程已经执行完毕
3、同步块和同步方法的实现方式

对于同步块的实现使用了monitorenter和monitorexit指令,而同步方法则是依靠方法修饰符上的ACC_SYNCHRONIZE来完成的。

无论采用哪种方式,其本质是对一个对象的监视(monitor)进行获取。

这个获取的过程是排他的,也就是同一时刻只能有一个线程获取到由synchronized所保护对象的监视器。

4、ThreadLocal的使用

ThreadLocal是一个线程变量,以ThreadLocal对象为键,任意对象为值的存储结构。

这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的值。

5、池化技术

池化技术指的是预先创建若干数量的线程,并且不能由用户直接对线程的创建进行控制。

在这个前提下重复使用固定或较为固定数目的线程来完成任务的执行。

这样做的好处是:

  • 消除了频繁创建和消亡线程的系统资源开销
  • 面对过量任务的提交能够平缓的劣化

第五章:Java中的锁

1、Lock接口

Lock接口的实现基本都是通过聚合了一个同步器(AQS)的子类来完成线程访问控制的。

子类推荐被定义为自定义同步组件的静态内部类,例如常用的ReentrantLock中的公平锁和非公平锁。

2、队列同步器

同步器提供的模板方法基本分为三类:

独占式获取与释放同步状态、共享式获取锁与释放同步状态和查询同步队列中的等待线程情况。

同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。

可以这样理解二者之间的关系:

锁是面向使用者的,它定义了使用者与锁交互的接口,隐藏了实现细节;

同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。

锁和同步器很好的隔离了使用者和实现者所需关注的领域。


以上所涉及到的图来源于以下博客:

内存屏障的原理

Java内存模型

浅谈偏向锁、轻量级锁和重量级锁

知乎程序员cxuan(内存屏障类型表)

posted @ 2021-09-02 13:12  M-Anonymous  阅读(198)  评论(1编辑  收藏  举报