《Java并发编程的艺术》读书笔记

《Java并发编程的艺术》读书笔记,第1张

《Java并发编程的艺术》读书笔记 一、总结

这本书挺好的,算是二刷了吧,认真看了一遍,然后写这篇博客又从头看了一遍,把一些重要的知识点记录了下来

原理讲的挺透的,就是内容好像不是块状的,而是在这几章串着讲的,估计是因为vloatile、锁、CAS、JMM这些联系比较紧密, 经典不愧是经典,推荐看完这个在去看《Java并发编程实战》,因为那本讲的没有那么细,偏概括。

涉及

  1. 并发编程的挑战
  2. 并发机制的底层实现
  3. 锁的升级与对比
  4. Java内存模型
  5. Java并发基础
  6. Java并发容器和框架
  7. Java中的13个原子 *** 作类
  8. Java中的线程池
  9. Executor框架
二、内容 1、并发编程的挑战

上下文切换

  • 即使是单核处理器也支持多线程处理代码,因为cpu会给每个线程分配时间片,不停的切换线程,让我们感觉线程是在同时执行的
  • 在线程切换的前,需要保留上一个线程任务的状态,以便下一次重新切换回这个任务,所以任务从保存到在加载的过程就是依次上下文切换

如何避免上下文切换

  • 无锁编程:多线程竞争锁的时候,会上下文切换,因此可以使用采用避免锁的方法进行多线程处理数据,如将数据的ID经过hash之后分段,不同的线程处理不同分段的数据
  • CAS算法:非阻塞自旋CompareAndSwap
  • 使用最少线程:避免任务太少创建的线程太多,可以使用jstack命令查看WAITTING状态的线程
  • 使用协程:在单线程里实现多任务调度,并在单线程里维持多个任务间的切换

实现死锁

package henu.soft.xiaosi.third.test;

public class DeadLock {
    private static String A = "xiaosi";
    private static String B = "henu";


    public static void main(String[] args) {
        new DeadLock().deadLock();


    }
    public void deadLock(){

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (A){
                    try {
                        System.out.println(Thread.currentThread().getName() + "拿到A");
                        Thread.sleep(2000);

                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                    synchronized (B){
                        System.out.println(Thread.currentThread().getName() + "拿到A尝试获取B");
                    }
                }
            }
        },"t1");

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (B){
                    try {
                        System.out.println(Thread.currentThread().getName() + "拿到B");
                        Thread.sleep(2000);

                    } catch (InterruptedException e) {
                        e.printStackTrace();

                    }

                    synchronized (A){
                        System.out.println(Thread.currentThread().getName() + "拿到B尝试获取A");
                    }
                }
            }
        },"t2");

        t1.start();
        t2.start();
    }
}

2、并发机制底层实现原理

概述

  • Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM内,JVM执行字节码,最终需要转化为汇编指令在CPU执行
  • Java中所使用的并发机制依赖于JVM的实现和CPU的指令

volatile的定义

  • 在Java语言规范第三版中对volatile的定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能准确和一致的更新,线程确保通过排它锁单独获取这个变量。大致意思就是单单的valatile可以应付共享读的问题,对于多个线程写,应该加上其他排它锁(synchronized、Lock等)
  • volatile是轻量级的synchronized,保证可见性,禁止指令重排,不保证原子性(对单个变量来说保证读、写原子性,但是对复合 *** 作类似volatile++不保证)
  • 合理使用相比synchronized可以降低成本,因为他不会引发上下文切换和调度

volatile可见性的实现原理

  • 多核心的cpu,每个核心内部都有自己的 缓冲行(可以理解为寄存器,用于拉去内存的数据,处理之后再同步到主内存)
  • 有volatile修饰的共享变量在进行写 *** 作时候会出现第二行汇编代码,即lock add xxx,这个指令在多核处理器会触发两件事,也就是保证各个核心的缓存一致性
  • 1、将当前处理器缓存行数据 回写到系统内存
  • 2、这个写回内存的 *** 作会使在其他CPU里缓存的该内存地址的数据无效,每个核心会不断的嗅探总线上传播的数据,检查自己的缓存值是不是过期了,过期了就要重新从内存拉去

注意

  • 锁的语义决定了临界区代码执行具有原子性,这意味着即使是64位的long、double类型变量,只要它是volatile变量,对该变量的读、写就具有原子性,如果是对多个volatile *** 作或者类似volatile++的复合 *** 作,这些 *** 作整体上不具有原子性
  • 简而言之,volatile具有可见性(对任意变量读,总是在最后一个线程写之后)、原子性(单个变量的读、写,而不涉及复合 *** 作)

synchronized定义

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

当一个线程访问同步代码块时候,必须先得到锁,退出或者抛出异常时必须释放锁,锁存储在哪里?保存了哪些信息?

  • JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但是两者的实现细节不一样
  • 代码块同步使用的是monitorenter和monitorexit指令实现的,monitorenter指令是在编译后插入同步代码块的开始位置,而monitorexit是插在方法结束和异常处,这两个指令必须配对出现
  • 方法同步是使用另外一种方式实现的,JVM规范并没有说明,但是方法的同步人也可以使用这两个指令实现,每个对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态,线程执行到monitorenter指令的时候,将会尝试回去对象所对应的monitor的所有权,尝试获取对象锁

Java对象头

  • 对象头的存储的内容:Mark Word、Class metadata Address 、Array length(如果是数组)
  • synchronized用的锁是存在Java对象头里的Mark Word里面的,也就是在堆区的对象实例内存首地址空间,存储的信息有hashCode、分代年龄、锁信息等
  • 在运行期间,Mark Word里面存储的数据会随着锁标志位的变化而变化

3、锁的升级与对比

偏向锁

  • HotSpot的作者研究发现,大多数情况下,锁不仅存在多线程竞争,而且总是由一个线程多次获的,为了让线程获得锁的代价更低从而引入了偏向锁,注意偏向锁只会在锁标志位为01,也就是无锁的情况下才会起作用。
  • 当一个线程访问同步块并获得锁(开始为无锁状态,锁标志位为01,线程进入获得偏向锁,修改偏向锁标志位0为1),会在对象头和栈帧的锁记录 里面CAS存储偏向的线程ID,以后当该线程进入和退出同步块不需要进行CAS *** 作来加锁和解锁,只要简单的测试一下对象头的Mark Word里是否存储当前线程的偏向锁(比对Mark Word 和 栈帧所记录的线程ID是否一致,也就是是否是自己拿到了锁)
    • 测试成功,表示当前线程获取了锁
    • 如果测试失败,CAS尝试替换Mark Word 失败后,(也就是自己做为新的线程竞争锁,Mark Word中偏向锁偏向的线程ID指的不是自己),则需要在测试一下Mark Word中偏向锁的标志是否设置成1(也就是表示当前是偏向锁)
    • 1、偏向锁标志位如果是0,证明其他线程还在持有偏向锁,证明持有偏向锁锁的线程需要撤销偏向锁,暂停之前持有偏向锁的线程,修改Mark Word变为无偏向锁状态0,之后需要使用CAS竞争;
    • 2、偏向锁标志位如果是1,证明是之前持有偏向锁线程已经不存活了,更改当前偏向线程,将Mark Word的线程ID改为新的线程(这时之前竞争的线程可能已经执行完毕,从而不需要偏向锁,自己的线程获得偏向锁)

偏向锁的撤销(升级)

  • 偏向锁使用的是等待出现竞争才会释放锁的机制,所以当其他线程尝试竞争偏向锁是,持有偏向锁的线程才会释放锁,将标志为设置为0,如举栗:A线程先进去同步块,获得偏向锁,但是还没执行完,此时B线程进入竞争。
  • 线程出现竞争,线程A需要等到全局安全点(这个时刻没有正在执行的字节码)。他会首先暂停拥有偏向锁的线程A,然后检查持有偏向锁的线程A是否存活着
    • 如果不存活,则将对象头设置为无锁状态,清除之前的偏向线程A的ID,即锁标志为01,偏向锁标志为1,当前线程A不在用锁或者是不可运行了,其他线程B可重新获得偏向锁。
    • 如果存活,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录 和 Mark Word
      • 1、偏向锁偏向其他线程:A线程先进去同步块,获得偏向锁,但是还没执行完,此时B线程进入竞争,但是A剩下的执行已经不需要锁,或者是同步块已经执行完毕,这是B线程获取偏向锁
      • 2、要么就是偏向锁锁撤销:A线程和B线程都竞争,需要撤销偏向锁,完成锁升级,升级为轻量级锁

关闭偏向锁

  • 偏向锁在Java 6 和 Java7 里默认是启用的,但是他在程序启动几秒中之后才激活
  • 如果有必要可以使用JVM参数来关闭延迟--XX:BiasedLockingStartupDelay=0
  • 如果锁通常情况下都处于竞争状态,那么可以关闭偏向锁-XX:-UseBiasedLicking=false,那么程序会默认进入轻量锁级别

轻量级锁

  • 线程在执行同步代码块之前,JVM会首先在当前线程的栈帧中创建用于存储当锁记录的空间,并将对象头中的Mark Word复制到栈帧的锁记录中,官方称为Displaced Mark Word。

  • 线程获取轻量级锁:然后线程尝试使用CAS将对象头中的Mark Word 替换为指向 当前线程栈帧锁记录的 指针(地址),锁标志位由无锁的01改为轻量级锁的00

    • 1、如果成功,表示没有竞争,当前线程获得锁
    • 2、如果失败,表示当前锁存在竞争,而且持有轻量级锁的线程还没解锁,当前线程自旋CAS尝试(也就是自旋一段时间等待锁的释放)获得锁,自旋失败之后,就会修改Mark Word的锁标志位00-->10,锁膨胀为重量级锁,然后当前线程就会阻塞
  • 持有轻量级锁的线程CAS解锁:使用原子CAS *** 作将Displaced Mark Word替换到对象头

    • 1、如果成功,表示没有竞争,顺利解锁
    • 2、如果失败,表示当前锁存在竞争,而且其他自旋失败的线程已经修改了Mark Word,此时已经是重量级锁,释放锁,并唤醒阻塞的线程

举栗

  • 0、Mark Word开始标识无锁状态
  • 1、线程A、B线程访问同步块(因为一开始就存在竞争,偏向锁很快升级为轻量级锁,A、B线程分配栈空间并复制Mark Word)
  • 2、A线程先于B线程CAS成功修改了Mark Word,也就是获取了轻量级锁,标志就是修改Mark Word内容为指向线程栈A帧锁记录的指针(地址)
  • 3、B线程接着进行CAS也想替换Mark Word指向自己线程B的指针(地址),也就是也想获得轻量级锁,但是A线程已经修改了Mark Word,导致B线程CAS失败(如自旋10次,发现A线程还在占用锁,就会自旋失败)
  • 4、这个时候线程B自旋失败发现存在竞争,即发现线程A还没释放锁,锁就进行膨胀,成为重量级锁,标志就是B线程修改Mark Word的内容为指向重量级锁标志,然后B线程进行阻塞
  • 5、此时线程A执行完同步块进行CAS释放轻量级的时候自然也会失败,因为线程B已经修改了Mark Word,然后线程A会释放锁,唤醒正在阻塞的线程B,开始重新竞争重量级锁
  • 为了避免无用的自旋(比如获取锁的线程被阻塞了),一旦锁升级为重量级锁,就不会再恢复到轻量级锁,当处于重量级锁的状态,其他线程试图获取锁的时候,都会被阻塞住,当持有锁的线程释放锁之后,会唤醒其他进程重新竞争重量级锁

锁的优缺点对比

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

参考:https://segmentfault.com/a/1190000022904663

Java如何实现原子 *** 作

  • 使用锁:锁机制保证了只有获得了锁的线程才能够 *** 作锁定的内存区域,JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。除了偏向锁,JVM实现锁的方式都采用了循环CAS,即当一个线程想进入同步块的时候都使用循环CAS来获取锁,退出同步块的时候,使用CAS释放锁
  • 使用循环CAS:从Java1.5时候,JDK的并发包里提供一些类来支持原子 *** 作,如AtomicBoolean、AtomicInteger、AtomicLong等

CAS实现原子 *** 作的三大问题

  • ABA问题:
    • 1、因为CAS需要在 *** 作之的时候,检查值是否变化,如果没有发生变化则更新,但是如果一个值变化A–>B–>A,这时候使用CAS进行检查的时候会发现他的值没有变,但是实际上却变了。
    • 2、解决:使用版本号,在变量前面追加上版本号,每次更新都把版本号加1,那么A–>B–>A就会变成1A–>2B–>3C,从Java1.5开始,JDK的Atomic包里面提供了一个类AtomicStampedReference来解决ABA问题,这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并检查当前标志是都等于预期标志,如果全部相等,都以原子方式将该引用和该标志的值设置为给定的更新值
  • 循环时间长开销大
    • 1、自旋CAS如果长时间不成功,会给CPU带来非常大开销,如果JVM能够支持处理器提供的pause指令,那么可以延迟流水线执行指令,而且可以避免在退出循环的时候因内存顺序冲突而引起CPU流水线被清空,从而提高CPU的效率。
  • 只能保证一个共享变量的原子 *** 作
    • 1、对于多个共享变量 *** 作时,这个时候就可以用锁,还有一种技巧值将多个变量合成一个变量 *** 作,自JDK1.5之后,提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象进行CAS *** 作
4、Java内存模型

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

  • 线程之间如何通信:共享内存(隐式通信)和消息传递(显示通信)
  • 线程之间如何同步:控制不同线程间 *** 作发生相对的机制

Java的并发采用的是共享内存模型,通信总是隐式进行的,内存模型的抽象结构

  • 所有的实例域、静态域和数组元素都存在堆内存中,各线程共享
  • 线程之间的通信有JMM控制,定义了线程主存之间的抽象关系
  • 从抽象的角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有本地内存,本地内存存储了该线程以读、写共享变量的副本,本地内存是JMM的一个抽象概念,并不是真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化

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

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

简单理解就是:源代码—>1、编译器优化—>2、指令级并行重排序—>3、内存系统重排序—>最终执行的指令序列,这些重排序可能会导致程序出现内存可见性问题

  • 1、对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止,但是存在数据依赖性的一定要禁止)
  • 2、对于处理器,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barries指令),通过内存屏障指令来禁止特定类型的处理器重排序

数据依赖性三种类型(从单个处理器或者是单个线换内的会被编译器、处理器考虑数据依赖性)

  • 写后读
  • 写后写
  • 读后写

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

  • 编译器、处理器、runtime都必须遵守as-if-serial语义
  • 遵守这个语义的编译器、处理器、runtime为单线程的程序员创建一个幻觉:单线程程序是按照程序的顺序来执行的,即发生或者不发生重排序对结果无影响
  • as-if-serial保证到单线程程序结果不会变,happens-before保证正确同步的多线程执行结果不会改变

在多线程下,对存在控制依赖的 *** 作重排序,也可能会改变程序的执行结果

  • 顺序一致性模型:理想化的模型
    • 1、同步情况下(使用一把监视器锁),严格串行执行A1—>A2—>A3—>B1—>B2—>B3
    • 2、未同步的情况下,可能为B1—>A1—>A2—>B2—>A3—>B3,所有的线程整体看起来是无序的,但是能看到一个一致的整体执行顺序,之所以能得到这样的保证,因为顺序一致性内存模型中的每个 *** 作必须立即对线程可见
  • 但是在JMM中就没有这个保证,
    • 1、未同步的程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的 *** 作执行顺序也可能不一致。比如当前线程写数据到缓冲区,还没刷会主存,那么就对其他线程不可见。
    • 2、同步的程序(加上synchronized),对于临界区的内的代码可以重排序(但是不允许临界区的代码 逃逸出 临界区之外,那样会破坏监视器的语义),JMM会在退出临界区、进入临界区这两个关键时间点做一些特殊处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图

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

  • 线程执行时读取到的值,要么是之前某个线程写入的值,要么就是默认值(0,null,False),不会出现读取到的值不会无中生有(Out Of Thin Air)的冒出来
  • 为了实心最小的安全性,JVM在堆上分配对象时候,首先会对内存空间进行清零,然后才在上面分配对象
  • JMM不保证对64位的long型和double型的变量写 *** 作具有原子性,在32位的处理器上,64位的写 *** 作会被拆分为两个32位的写 *** 作,且这两个32位的写 *** 作会被分配到不同的写事务中执行,而64位数据的读 *** 作确是一个读事务,容易造成读事务插到两个写事务之间造成只看到读了高32位的无效数据。
  • 不同于 顺序一致性模型总线仲裁方式同时只允许一个处理器读、写数据到内存。因此在32位的处理器上,要求对64位数据 *** 作具有原子性需要的开销非常大,因此JVM鼓励但是不强求对64位型变量的写 *** 作具有原子性
  • 在JSR-133之后,也就是JDK1.5之后,仅仅只允许把一个64位的long、double型变量的写 *** 作拆分为两个写数据,读 *** 作必须要有原子性

注意

  • JMM输入语言级别的内存模型,他确保不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器、处理器重排序,为程序员提供一致的内存可见性保证
  • 现代的处理器使用写缓冲区临时保存相向内存写入的数据,写缓冲区可以保证指令流水线的持续运行,他可以避免由于处理器等待向内存写入数据产生的延迟(因为处理器速度快),同时还会有批处理的方式刷新缓冲区,以及合并写缓冲区中对同一地址的多次写…
  • 但是每个处理器上的写缓冲区,仅对当前处理器自己可见,这个 *** 作会对内存 *** 作的执行顺序产生重要的影响:处理器对内存的读、写 *** 作的执行顺序,不一定与内存实际发生的读、写顺序一致,也就是发生重排序

举栗

两个处理器同时执行:

处理器 A                         B
		a = 1;						b = 2;
		x = b;						y = a;

运行结果:

初始状态:a = b = 0;
执行之后:x = y = 0;

原因:

因为处理器执行写 *** 作的时候,即a = 1,b = 2;先会写入自己的写缓冲区

然后此时内存中的 a = 0;b = 0;此时执行x = b;y = a的读 *** 作,读取的为0

这就是处理器的内存 *** 作被重排序了,读 *** 作(当前处理器的读 *** 作)排到了写 *** 作(另外一个处理器的写 *** 作)的前面

  • 可支持的重排序:现代的每个处理器都支持写-读 *** 作进行重排序
  • 特定类型的重排序:一般处理器都不支持对存在数据依赖性的 *** 作进行重排序(通过插入内存屏障实现)

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

happens-before原则

  • 从JDK1.5开始,Java使用的是新的JSR-133内存模型,该内存模型使用happens-before的概念阐述 *** 作之间的内存可见性
  • 在JMM中,如果一个 *** 作执行的结果需要对另一个 *** 作可见,那么这两个 *** 作之间必须要存在happens-before关系(既可以在一个线程之内,也可以在不同的线程之间)
  • 两个 *** 作之间存在happens-before关系,并不意味着前一个 *** 作必须要在后一个 *** 作之前执行,他只是要求前一个 *** 作(执行的结果)对后一个 *** 作可见且前一个 *** 作按顺序排在第二个 *** 作之前。
  • 一个happens-before规则对应一个或多个编译器、处理器重排序规则,对于程序员来讲,happens-before规则简单易懂,避免了去了解内存可见性涉及的复杂的重排序规则及其底层实现原理

规则如下

  • 程序顺序原则:一个线程中的每个 *** 作,(先行发生)happens-before与该线程中的任一后续 *** 作
  • 监视器锁规则:对一个锁的解锁,(先行发生)happends-before于随后对这个锁的加锁
  • volatile变量规则:对一个volatile域的写(先行发生)happens-before于任意后续对这个volatile域的读
  • 传递性:如果A(先行发生)happens-before B,且B happens-before于 C ,那么A happens-before C。
  • start()规则:如果线程A执行 *** 作ThreadB.start()启动线程B,那么A线程的ThreadB.start() *** 作happends-before于线程B中的任意 *** 作
  • join()规则:如果线程A执行 *** 作ThreadB.join()并成功返回,那么线程B中任意的 *** 作happens-before于线程A从ThreadB.join() *** 作成功返回

JMM实现volatile的内存语义

  • JMM会限制编译器、处理器重排序,体现在编译器在生成字节码时,插入内存屏障进行限制
  • JSR-133增强了volatile的内存语义,之前允许普通变量和volatile变量重排序,但是这样会造成普通变量在多线程下的读、写内存不可见性,也就是在旧的语义中没有锁的释放-获取具有的内存语义,为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的语义:严格显示编译器、处理器对volatile变量与普通变量的重排序,确保对volatie的写、读和锁的释放、获取具有相同的语义


JMM实现锁的内存语义

  • 当线程释放锁的时候,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
  • 当线程获取锁的时候,JMM会把该线程的本地内存置位无效

锁获取与volatile读具有相同的内存语义,以ReentrantLock中为例

  • 实现依赖于Java同步框架AbstractQueuedSynchronizer(AQS),AQS使用的是一个整形的volatile变量(state,类似计 *** 的临界区资源)来维护同步状态,在获取锁都会先读取AQS的volatile的state变量,释放的时候最后会写这个变量
  • 分为公平锁和非公平锁,默认为非公平锁,继承体系及其源码

Lock接口

public interface Lock {

    void lock();


    void lockInterruptibly() throws InterruptedException;

  
    boolean tryLock();


    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;


    void unlock();


    Condition newCondition();
}

使用公平锁调用过程

使用非公平锁加锁调用过程

AQS同步器的CAS调用的是本地的Unsafe方法的compareAndSetState(int except,int update)

concurrent并发包的实现模式( AQS、非阻塞数据结构、原子变量类等都是这种模式实现的)

  • 首先,声明共享变量为volatie
  • 然后,使用CAS的原子条件更新来实现线程间的同步
  • 同时,配合以volatile的读、写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信

final域的内存语义,对于final域,编译器和处理器要遵守两个重排序规则

  • final域基本类型写:在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个 *** 作不能重排序,也就是禁止把final域的写重排序到构造函数之外,原理就是编译器会在final域写之后,构造函数return之前,插入一个StoreStore屏障,也就是确保在对象引用为任意线程可见之前,对象的final域已经被正确的初始化之后了,而普通域不具有这个保证,
  • final域基本类型读:初次读取一个包含final域的对象引用,与随后初次读到这个final域,这两个 *** 作之间不能重排序,编译器会在final域 *** 作的前面加一个LoadLoad屏障
  • final域为引用类型:在构造函数内对一个final引用的对象的成员域写入,与随后在构造函数外把这个被构造对象的引用赋值给另外一个因哟引用变量,这两个 *** 作之间不能重排序,也就是final引用不能从构造函数"逃逸"

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

  • 在旧的JMM中,一个最严重的缺陷就是线程可能看到final域的值会变化,如一个线程当前看到一个整型final域的值为0(还未初始化之前的默认值),过一段时间,这个线程在去读这个final域的值时,其他线程完成初始化设置为1了,常见的就是String的值可能会改变
  • 为了修复这个漏洞,JSR-133专家组增强了final的语义,通过为final域增加写和读的重排序规则,不需要使用lock和volatile就能保证任意线程都能看到这个final域在构造函数中被初始化的值。

DCL单例懒汉模式

  • 使用synchronized加到geiInstance()方法上,在方法内加上一层判断可以保证正确单例,但是性能太低,使用DCL双锁检查就是为了解决多项成频繁调用synchronized方法带来的性能开销。
  • 还不完美的DCL:不使用synchronized锁方法了,而是在 第一层if判断 实例还没创建 之后,锁住xxxx.class,内部再加一层判断实例是否创建,没有创建再创建。这也就避免了对象创建好之后,执行getInstance()不在需要获取锁,因为直接进不去 第一层if判断了。
  • 加上volatile禁止重排序:但是还不完美,因为创建对象非原子 *** 作,分为,分配对象内存空间、初始化对象、设置对象变量指向内存空间,其中后两步 *** 作是可以重排序的,因此不能保证单例,可以参考往期博客:Java并发编学习篇6_单例模式、理解CAS、原子引用解决ABA问题

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

  • 类的实例被创建
  • 类的声明的静态方法被调用
  • 类的声明的静态字段被赋值,或者被使用
  • 类是一个顶级类,而且一个断言语句嵌套在T内部被执行

对于多线程下类初始化的内存可见性,需要分为4个阶段

  • 1、第一阶段,通过在Class对象上同步(获得Class对象的初始化锁),来控制类和接口的初始化,这个获取锁的线程会一直等待,直到当前线程能获取到这个初始化锁
  • 2、第二阶段,线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待
  • 3、第三阶段,线程A设置state = initalized ,然后唤醒在condition中等待的线程,初始化完成
  • 4、第四阶段,线程B结束类的初始化过程
5、Java并发基础

线程的6种状态

理解中断

  • 中断可以理解为线程的一个标识位属性,他表示一个运行中的线程是否被其他线程进行了中断
  • 中断好比其他线程对该线程打了个招呼,其他线程通过调用该线程的interrupt()方法对其进行中断 *** 作

过期的线程停止方法

  • suspend()在调用之后,不会释放锁,而是占着资源进入睡眠状态,容易造成死锁
  • stop()在调用之后,不会保证资源正常释放,通常是没有给线程释放资源的机会,容易导致线程处于不确定状态
  • 安全的终止方法就是使用中断,或者是利用volatile boolean变量控制需要停止的任务

线程之间通信wait()、notify()



6、Java中的锁

Lock是JDK1.5新增的接口,相比synchronized缺少了隐式释放锁,但是优点有

  • 可 *** 作性
  • 可中断的获取锁:能够响应中断,可以抛出中断异常,同时释放锁
  • 超时获取锁
  • 尝试非阻塞的获取锁

队列同步器AQS,用来构建锁、其他同步组件的基础框架,

  • 通过使用int类型的成员变量state表示同步状态,通过内置的FIFO队列完成资源获取线程的排队工作
  • 同步器主要的使用方式是继承,子类通过实现他的抽象方法管理同步状态,同步器本身没有实现任何接口,它仅仅定义了(getState、setState、compareAndSetState)三个方法来供自定义同步器子类组件使用,如ReentrantLock、ReentrantReadWriteLock和countDowmLatch
  • 它简化了锁的实现方式,屏蔽了同步状态管理器、线程的排队、等待和唤醒等底层 *** 作,基于模板方法模式设计的

可供子类组件自定义重写的方法(如决定是独占锁还是共享锁)

  • tryAcquire(int arg)
  • tryRelease(int arg)
  • tryAcquireShared(int arg)
  • tryReleaseShared(int arg)
  • isHeldExclusively()

然后就是AQS的模板方法(也就是供自定义组件默认调用的底层方法,可以不重写)

  • acquire(int arg)
  • acquireInterruptibly(int arg)
  • tryAcquireNanos(int arg,long nanos)
  • acquireShared(int arg)
  • acquireSharedInterruptibly(int arg)
  • tryAcquireSharedNanos(int arg,long nanos)
  • release()
  • releaseShared()
  • getQueueThreads():获取在同步队列上的线程集合

自己写一个简易版Lock锁步骤

  • 1、新建MyLock类实现Lock接口
  • 2、新建静态内部类Sync实现AbstractQueuedSynchronizer,然后根据自己想设计的锁重写如3个可重写的方法
  • 3、接着在MyLock类内新建Sync实例对象
  • 4、接着在MyLock类内新建系列加锁lock()、tryLock()、解锁unlock()等方法,方法内部就是使用Sync实例去调其实是重写的那些方法或者AQS的模板方法

调用原理(以ReentrantLock为例)

Sync调用的是AQS的acquire(),if判断的条件是是否能获得锁

  • 1、也就是调用Sync重写的方法tryAcquire(),返回是否能拿到锁
  • 2、在拿不到锁的前提下,接着以自己线程新建结点,CAS尝试添加到同同步队列的尾部,不断循环尝试获取锁,等待获取锁之后才能从当前方法返回

对于1,则是调用Sync重写的方法,返回是否已经持有锁,持有的话直接从该方法返回

对于2,死循环获取锁(只有当前驱节点是首节点才有资格获取锁),tryAcquire()还是调用的Sync重写的方法

AQS底层实现

  • 同步双端队列:遵循FIFO,当前线程获取同步状态失败之后,就会当前线程及其等待状态等信息 构建一个 结点。并加入同步队列,同时会阻塞线程,当前持有同步状态的线程释放同步状态后,会把同步队列的首节点线程唤醒,使其再次尝试获取同步状态。
  • 同步队列节点:保存同步状态获取失败线程的引用、等待状态、前后节点的指针、节点的属性和名称描述等
  • 设置尾结点:同步器提供一个CAS设置尾结点的方法compareAndSetTail(),同步器通过死循环 来保证节点的正确添加,只有成功添加到尾结点之后,当前线程才能从该方法返回,否则不断的尝试设置,简单理解就会多个线程尝试把自己添加到尾结点变得串行化了
  • 设置首节点:通过当前获取同步状态成功的线程来完成的,因为同时只有一个线程能获取同步状态,因此不需要CAS方法,只需要将首节点设置成原首节点的后继节点并断开原首节点的next即可
  • 节点进入同步队列:进入之后,每个节点就一直在循环判断前驱节点是不是头结点,阻塞当前节点的线程,直到获得同步状态或者被其他线程中断,从该方法返回不在阻塞

只有前驱节点是头结点才能尝试获取同步状态,节点和节点在循环检查的过程中不通信,而是只根据前驱节点是不是头结点判断,这样就符合FIFO原则,并且也便于对通知过早的通知的处理(过早通知是由于前驱节点不是头结点的线程由于终端而被唤醒)

  • 头结点是成功获取同步状态的节点
  • 维护同步队列的FIFO原则

重入锁与公平锁

  • 支持重入,就是支持一个线程对同一个资源的重复加锁
  • synchronized关键字隐式大的支持重进入,ReentrantLock也支持重入
  • synchronized关键字支持的是非公平锁,ReentrantLock通过构造函数传参可以指定公平、非公平锁

读写锁ReentrantReadWriteLock

  • 维护一对锁,一个读锁,一个写锁,并发性比其他排它锁要高
  • 支持公平、非公平,可重入、锁降级(遵循获取写锁、获取读锁在释放写锁的次序,写锁能降级为读锁)
  • 依赖自定义同步器实现功能,需要在一个同步状态变量state上维护多个读线程和一个写线程的状态,具体后就是 按位切割使用,高16位表示读,低16位表示写,不为0时,表示锁已经被线程获取
  • 读写互斥,读读共享

锁降级指的是写锁降为读锁

  • 如果当前线程持有写锁,释放之后,在获取读锁,这种分段的情况不能称之为锁降级
  • 锁降级是 把持住当前拥有的写锁,在获取到读锁,随后释放拥有的写锁的过程(其实简单理解就是为了方式当前释放写锁后好没加上读锁,但是其他线程加上了写锁,目的就是为了利用读锁防止其他线程加写锁)

LockSupport工具,定义一组公共静态方法,支持阻塞、唤醒线换

  • park()
  • parkNanos()
  • parkUntil()
  • unpark()

Condition接口,依赖Lock对象

  • 支持多个等待队列
  • 支持等待状态响应中断
  • 支持超时等待

阻塞、唤醒实现分析

  • 每个Conditio对象都包含一个等待队列,如果调用condition.await()方法,当前线程就会释放锁,构造成节点加入等待队列并进入等待状态
  • 新增等待队列尾结点不需要CAS,因为只有当前持有锁的线程才能执行await(),因此由锁保证线程安全
  • 如果从同步队列、等待队列的角度看调用await,就是同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中,同样调用signal反之
7、Java并发容器和框架

ConcurrentHashMap的实现原理

  • JDK1.7使用的是分段锁,JDK1.8之后改为Node数组

  • 参考JavaGuide,挺详细的:https://javaguide.cn/

ConcurrentlinkedQueue线程安全的队列

  • 使用阻塞算法
  • 使用非阻塞算法

Java中的阻塞队列,7种

  • ArrayBlockingQueue
  • linkedBlockingQueue
  • PriorityBlockingQueue
  • DelayQueue
  • SynchronousQueue
  • linkedTransferQueue
  • linkedBlockingDeque

Fork/join框架

  • 并行执行任务的框架,工作窃取算法
8、Java中的13个原子 *** 作类

在JDK1.5之后在开始提供Atomic包,这个包下的原子 *** 作类提供一种用法简单、性能高效、线程安全的更新一个变量的方式,基本实现方式都是Unsafe类的CAS方法,只有三种是因为Unsafe类只提供了三种,其他可在此基础上实现

基本类型类

  • AtomicBoolean
  • AtomicInteger
  • AtomicLong

原子更新数组

  • AtomicIntegerArray
  • AtomicLongArray
  • AtomicReferenceArray

原子更新引用类型

  • AtomicReference
  • AtomicReferenceFieldUpdater
  • AtomicMarkableReference
9、Java中的并发工具类

主要有

  • 1、等待多线程完成的CountDownLatch:
    • 允许一个多个线程等待其他线程完成 *** 作之后,再接着执行,类似join()的功能,如在main线程调用t1.join()就可以使t1线程执行完之后,才接着执行main线程。而CountLatch功能更强大,通过构造参数传入一个整数计数器,调用CountLatch.countDown()导致计数器的值就会减1,直到为0当前main线程执行。
    • 需要注意的是不能重新初始化、修改计数器的值。
    • 应用场景比如需要处理多个分任务运算,最后需要将分任务结果合并
  • 2、同步屏障CyclicBarrier:
    • 可循环利用的屏障,他要做的事情是让一组线程到达一个屏障(阻塞点),直到最后一个线程到达屏障时,屏障才会开门,所有被阻塞点拦截的线程才能执行。
    • 还支持一个高级的构造参数,当满足屏障开放的条件之后,优先执行指定的Runnable
    • 应用场景和上述类似
    • 需要注意的是和CountDownLatch不同的是计数器可以使用多次,如计算发生错误可以重置计数器
  • 3、控制并发线程数的Semaphore
    • 协调各个线程,保证合理的使用公共资源
    • 应用场景比如流量控制、数据库连接
  • 4、线程间交换数据的Exchanger
    • 线程间协作,提供一个同步点,在这个同步点,两个线程可以彼此交换数据
10、Java中的线程池

使用线程池的好处

  • 降低资源消耗:重用已创建的线程资源降低线程创建、销毁造成得消耗
  • 提高响应速度:任务到达可以不需要等待线创建就能执行
  • 提高线程的可管理性:线程是稀缺资源,不能无限制的创建,会降低系统的稳定性,使用线程池可以进行统一分配、调优、监控

实现原理

采用上述步骤的总体设计思路是为了执行execute()方法的时候,尽可能避免使用全局锁,在线程池预热之后(当前运行的线程数大于等于corePoolSize核心线程数),几乎所有的execute()方法就是将任务加入阻塞队列,这个规程不需要获取全局锁

线程池的创建

  • 可以使用静态工厂类Excutors.newXXX创建,不过阿里规范不推荐这种方式,因为动态根据CPU、IO密集型设置线程池更灵活高效
  • 可以使用ThreadPoolExcutor的构造参数创建,需要指定5个参数
    • 1、corePoolSize:核心线程数大小,注意有预热,当提交一个任务到线程池时候,只要当前线程数量不超corePoolSize,就会新建一个线程执行该任务,即使有其他空闲的线程,等到达到corePoolSize就不在创建
    • 2、runnableTaskQueue任务队列
      • ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO
      • linkedBlockingQueue:基于链表结构的阻塞队列,FIFO,吞吐量高于上者,静态工厂方法Excutors.newFixedThreadPool()使用了这个队列
      • SynchronousQueue:一个不存储元素的阻塞队列,每个插入必须等待另一个线程调用移除 *** 作,否则一直阻塞,吞吐量高于上者,静态工厂Excutors.newCacheThreadPool使用了这个队列
      • PriorityBlockingQueue:一个具有优先级的无限阻塞队列,有可能一些低优先级的任务一直无法执行
    • 3、ThreadFactory创建线程的工厂
    • 4、maximumPoolSize最大线程数
    • 5、RejectExecutionHandler拒绝策略
      • AbortPolicy:直接抛出异常
      • CallerRunsPolicy:只用调用者所在线程来运行任务
      • DiscardPolicy:不处理,丢弃掉,也可以根据应用换成那个经实现RejectExecutionHandler接口自定义策略,如记录日志或者持久化不能处理的任务
      • keepAliveTime:线程活动保持时间,线程池的工资线程空闲后,存活的时间,所以如果任务很多,并且每个任务执行的时间比较短,可以调大时间,提高线程的利用率
      • TimeUnit:线程活动保持时间的单位

提交任务

  • pool.execute():不需要返回值的任务,如Runnable
  • pool.submit():需要返回值的任务,线程池会返回Fature类型的对象,调用future.get()可以获得执行结果

关闭线程池,原理就是遍历每个线程,调用interrupt方法中断线程

  • shutdown:中断没有正在执行任务的线程
  • shutdownNow:中断所有线程

监控线程池的属性

  • taskCount:线程池需要执行的任务数量
  • completedTaskCount:已完成的任务数量
  • largestPoolSize:线程池曾经创建过得最大线程数量
  • getPoolSize:线程池的线程数量
  • getActiveCount:获取活动的线程数
11、Executor框架

Java线程既是工作单位,也是执行机制,从JDK1.5之后,工作单元包括Runnable和Callable,而执行机制有Excutor框架提供,在HotSpot VM模型中,

  • Java线程被一对一的映射为本地 *** 作系统的线程,Java线程启动时会创建一个本地 *** 作系统线程,线程终止时,这个线程也会被 *** 作系统回收
  • 上层模型就是用户级别的调度器如Executor框架将任务映射为固定数量的线程,下层模型就是由 *** 作系统内核控制

Excutor框架的结构主要由3部分组成

  • 1、任务:Runnable或者Callable接口
  • 2、任务的执行:核心接口Executor 和 ExecutorService implements Excutor接口,关键类ThreadPoolExecutor、ScheduledThreadPoolExecutor实现了ExcutorService接口
  • 3、异步计算的结果:包括Future接口和Future接口的实现类FutureTask

包含的类和接口继承关系

主线程需要先创建Runnable或者Callable接口的任务对象,工具类Executors可以把一个Runnable对象封装为一个Callable对象

  • Executors.callable(Runnable xxx)
  • Executors.callable(Runnable xxx,Object result)

然后可以把Runnable接口交给ExecutorService执行,如果调用submit(),则返回一个Future,在目前的JDK返回的是子类FutureTask

  • ExecutorService.execute(Runnable xxx)
  • ExecutorService.submit(Runnable xxx)
  • ExecutorService.submit(Callable xxx)

注意,由于FutureTask实现了Runnable,也可以直接创建FutureTask任务进行执行

Executor成员

  • 1、ThreadPoolExecutor
    • FixedThreadPoolExecutor:使用无界队列linkedBlockingQueue作为工作队列,最大容量为Integer.MAX_VALUE,因此线程数不会超过corePoolSize,maximumPoolSize将是无用参数,keepAliveTime将是无用参数,拒绝策略不会起作用
    • SingleThreadPoolExecutor:只有一个线程工作,也是无界队列linkedBlockingQueue
    • CachedThreadPoolExecutor:没有容量的队列,来个任务就要创建一个线程,容易导致创建线程过多而导致CPU和内存资源耗尽
  • 2、ScheduledThreadPoolExecutor:给定的延迟之后执行任务,与Timer类似,但是功能更强大、灵活。可以在构造函数中指定多个对应的后台线程,采用DelayQueue延时队列
    • SingleThreadScheduledExecutor
  • 3、Future
  • 4、Runnable、Callable接口

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/zaji/5595888.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-15
下一篇 2022-12-15

发表评论

登录后才能评论

评论列表(0条)

保存