并发编程总结

本文详细介绍了Java并发编程的概念,包括线程、进程、协程、Java内存模型和happens-before原则,以及并发编程的三大核心问题。讨论了线程的创建、线程间通信、线程池的原理和配置,特别提到了线程池关闭时的平滑过渡,以及ThreadLocal和InheritableThreadLocal的使用和内存泄漏问题。此外,文章还涵盖了死锁的定义、原因和解决方案,以及进程间通信和调度算法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

这里写目录标题

一、基本概念

1、线程

什么是线程

操作系统在运行一个程序的时候,就会为它创建一个进程。比如说我们启动一个java程序,操作系统就会创建一个java进程,在这个进程里面,我们可以创建很多线程,每个线程都拥有自己的程序计数器和栈,并且还能够访问共享的变量。线程是操作系统调度的最小单元,CPU在这些线程上高速切换,让使用者感觉这些线程是在同时执行

为什么使用多线程

并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升

创建多少线程合适

创建多少线程这个问题的本质是:尝试通过增加线程来提升 I/O 的利用率和 CPU 的利用率,努力将硬件的性能发挥到极致

对于单核 CPU,如果只有一个线程,执行 CPU 计算的时候,I/O 设备空闲;执行 I/O 操作的时候,CPU 空闲,所以 CPU 的利用率和 I/O 设备的利用率都是 50%,我们可以通过多线程去平衡 CPU 和 I/O 设备

对于多核 CPU,我们可以增加线程数提高 CPU 利用率,来降低响应时间(一个方法内使用多线程执行可并行的部分或者并行执行多个方法)

但是一味的增加线程数并可取

如果一个程序只有 CPU 计算,而没有 I/O 操作的话,多线程不但不会提升性能,还可能会使性能变差,原因是增加了线程切换的成本

我们首先要区分具体场景

  • CPU 密集型:纯 CPU 计算,不涉及 I/O 操作的场景

  • I/O 密集型:只要涉及 I/O 操作的场景都属于 I/O 密集型,因为 I/O 设备的速度相对于 CPU 计算来说都非常长

  • 对于 CPU 密集型:多线程的本质就是提升多核 CPU 的利用率,理论上 线程的数量 = CPU 核数 就是最合适的,工程上,线程的数量会设置为 CPU 核数+1,这样的话,当线程因为偶尔的内存页失效或者其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率

  • 对于 I/O 密集型:最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,对于单核CPU,最佳线程数 =1 +(I/O 耗时 / CPU 耗时),对于多核 CPU,最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

线程优先级

在java线程中,会通过一个整形变量 priority 来控制线程优先级,范围是一到十,默认是5,可以通过对应的set方法修改。理论上优先级高的线程分配时间片数量要多于优先级低的线程,但实际上操作系统可能不太会理会java线程对于优先级的设定

守护线程

Java的线程分为两类:

  • 用户线程:创建一个线程默认是用户线程,属性daemon = false
  • 守护线程:设置属性daemon = true时,就是守护线程

用户线程与守护线程的关系:

用户线程就是运行在前台的线程,守护线程就是运行在后台的线程,一般情况下,守护线程是为用户线程提供一些服务,比如在Java中,GC内存回收线程就是守护线程

JVM 与用户线程共存亡:

当所有用户线程都执行完成,只存在守护线程在运行时,JVM就退出,Java程序在main线程执行退出时,会触发执行JVM退出操作,但是JVM退出方法destroy_vm()会等待所有非守护线程都执行完,里面时用变量numberofnondaemonthreads统计非守护线程的数量,这个变量在新增线程和删除线程时会做增减操作

当JVM退出时,所有还存在的守护线程会被抛弃,既不会执行finally部分代码,也不会执行catch异常

main线程可以比子线程先退出:

main 线程退出前,通过 LEAVE() 方法,调用了 destroy_vm() 方法,但是在 destroy_vm() 方法里面等待着非守护线程执行完,子线程如果是非守护线程,则 JVM 会一直等待,不会立即退出。

线程在还没有通过start()方法启动前,可以通过修改daemon属性来将用户线程转变为守护线程,启动之后就不能修改了

其他特性:

  • 守护线程属性继承自父线程
  • 守护线程优先级比用户线程低

线程间通信

  1. volatile关键字:用volatile关键字来修饰一个变量,就相当于告知程序,对于该变量的访问都需要从共享内存中获取,对变量的改必须同步刷新回共享内存,这样就保证了所有线程对这个变量的可见性。这样的话,一个线程对这个变量进行改变的时候,所有线程都可以感知到变化
  2. 等待通知机制:使用 synchronized 配合 wait()、notify()、notifyAll() 这三个方法来进行是实现,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,调用 wait()方法,然后线程会被阻塞,进入等待队列中,同时释放线程持有的互斥锁,让其他线程有机会获得锁,进入临界区,当线程要求满足时,通知等待队列中的线程,被通知的线程想要重新执行,仍然需要获取到互斥锁(进入锁等待队列中 因为之前调用wait方法的时候释放了互斥锁)或者也可以使用Lock/condition来实现等待通知机制
  3. Thread.join()方法:如果一个线程实例A执行了threadB.join(),其含义是:当前线程A会等待threadB线程终止后threadA才会继续执行。
  4. ThreadLocal:ThreadLocal 其实是一个类似于 Map 的结构,它以 ThreadLocal 这个对象为 key,存进去的值为 vlaue,这个结构被附带在线程上,也就是一个线程可以根据一个ThreadLocal 对象查询到帮定在这个线程上的一个值
  5. InheritableThreadLocal:主线程开了一个子线程,但是我们希望在子线程中可以访问主线程中的 ThreadLocal 对象,也就是说有些数据需要进行父子线程间的传递,我们希望子线程可以看到父线程的 ThreadLocal,那么就可以使用 InheritableThreadLocal

线程切换

操作系统采用时分的形式调度运行的线程,操作系统会分出一个个的CPU时间片,线程会分到若干个CPU时间片,当线程的CPU时间片用完了就会发生线程调度,也就是线程切换,等待着下次分配。线程分配到CPU时间片的多少就决定了使用CPU资源的多少。

当发生线程切换的时候,操作系统要保存当前线程状态,并恢复另外一个线程的状态,这个时候就要使用程序计数器,记住下一条JVM指令的执行地址,这也是程序计数器必须线程私有的原因

线程切换的原因:

  • 线程的 cpu 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

内核态用户态

什么是用户态和内核态
  • CPU指令集:指令集是 CPU 实现软件指挥硬件执行工作的媒介,具体来说每一条汇编语句都对应了一条 CPU 指令,CPU 指令不止一条的,而是由非常非常多的 CPU 指令集合在一起,组成了一个、甚至多个的集合,每个指令的集合叫:CPU 指令集

  • CPU指令权限分级:CPU 指令是可以直接操作硬件的,要是因为指令操作的不规范,造成的错误是会影响整个 计算机系统 的,所以对CPU指令进行了分装操作,对CPU指令设置了权限,不同级别的权限可以使用的CPU指令是有限的

    • ring0:权限最高,可以使用所有的CPU指令

    • ring1

    • ring2

    • ring3:权限最低,仅能使用常规的CPU指令,这个级别的权限不能使用访问硬件资源的指令,比如 IO 读写、网卡访问、申请内存都不行,都没有权限

  • ring 0 被叫做 内核态,完全在 操作系统内核 中运行,由专门的 内核线程 在 CPU 中执行其任务

  • ring 3 被叫做 用户态,在 应用程序 中运行,由 用户线程 在 CPU 中执行其任务

用户态和内核态切换触发条件

Linux 中默认采用 1:1 线程模型,就是有一个 用户线程,就得在内核中启动一个对应的 内核线程,然后把这个 用户线程 绑定到 内核线程 上。

JVM 采用 Linux 默认函数库,也就是 PThread,1:1 线程模型。java new 一个 Thread 时,是创建了 1个用户线程和内核线程的,然后把用户线程绑定到内线线程中,ring3 的代码在 用户线程中执行,ring0 的代码切换到 内核线程 中去执行,然后使用 内核线程 接受 系统内核的调度,内核线程抢到 CPU 时间片后,用户线程就会激活执行代码

用户态和内核态的切换的实质就是用户线程和内核线程的切换

当在一系统中执行一个程序时,大部分时间时运行在用用户态下的,在其需要操作系统帮助的完成一些用户态自己没有特权和能力完成的操作时就会切换到内核态

用户态切换到内核态的三种方式

  1. 系统调用:这是用户态进程主动要求切换到内核态的一种方式。用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作
  2. 异常:当cpu在执行运行在用户态下的程序时,发生了一些没有预知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关进程中,也就是切换到了内核态
  3. 外围设备的中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果前面执行的指令是用户态下的程序,那么转换的过程自然就会是由用户态到内核态的切换。如硬盘读写操作的完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等

这三种方式是系统在运行时由用户态切换到内核态的最主要方式,其中系统调用可以认为时用户进程主动发起的,异常和外围设备中断则是被动的。从触发方式上看,切换方式都不一样,但是从最终实际完成由用户态到内核态的切换操作来看,步骤又是一样的,都相当于执行了一个中断响应的过程。系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本一致。

死锁

回答思路:死锁的定义-----死锁产生的原因(使用细粒度锁)-----死锁形成的条件-----如何预防死锁

死锁的定义:一组互相竞争资源的线程因互相等待,导致 “永久” 阻塞的现象

在使用锁的过程中我们会选择用不同的锁对受保护资源进行精细化管理,也就是使用细粒度锁,能够提升性能,但是使用细粒度锁是有代价的,这个代价就是可能会导致死锁

死锁形成的条件:

  • 互斥,共享资源 X 和 Y 只能被一个线程占用
  • 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X
  • 不可抢占,其他线程不能抢占线程 T1 占有的资源
  • 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待

我们只用破坏一个条件就可以避免死锁的发生

解决方案:

  1. 互斥:互斥这个条件没有办法避免

  2. 占用且等待:我们一次性申请所有的资源,这样就不存在等待了(使用等待 - 通知机制,线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁)

    具体实现:使用 synchronized 配合 wait()、notify()、notifyAll() 这三个方法来进行是实现,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,调用 wait()方法,然后线程会被阻塞,进入等待队列中,同时释放线程持有的互斥锁,让其他线程有机会获得锁,进入临界区,当线程要求满足时,通知等待队列中的线程,被通知的线程想要重新执行,仍然需要获取到互斥锁(进入锁等待队列中 因为之前调用wait方法的时候释放了互斥锁)

  3. 不可抢占条件:破坏不可抢占条件的核心是能够主动释放它占有的资源。synchronized 做不到这一点,synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,不能主动释放已经抢占的资源(java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的)

  4. 循环等待:破坏循环等待,需要对资源进行排序,然后按序申请资源(可以给资源加一个 id 属性,这个 id 属性可以作为排序的字段,申请资源时,我们可以按照从小到大的顺序来申请)

2、进程

什么是进程

进程间切换

进程间通信

进程调度算法

3、协程

协程可以理解为线程的线程。线程虽然提升了资源的利用率,但是也存在线程资源有限,而且大多数线程资源处于阻塞的状态,线程之间的开销虽然对比进程少了不少,但是上下文切换的切换开销也不小的问题。协程的出现在一定程度上解决了一些问题。协程的核心在于调度那块由他来负责解决,遇到阻塞操作,立刻放弃掉,并且记录当前栈上的数据,阻塞完后立刻再找一个线程恢复栈并把阻塞的结果放到这个线程上去跑,等达到一定条件后,再恢复原来的栈信息继续执行。协程在Java原生库上不支持的,要引入三方的库才支持。Kotlin和Go语言是原生支持协程的。

4、Java 内存模型

因为 CPU 和 内存的速度差异非常的大,为了合理的利用 CPU,平衡这两者的速度差异,CPU 难题wem加了缓存来均衡与内存的速度;同时会在编译的时候,进行指令重排序,使得缓存能够得到更加合理地利用,但是这样会带来两个问题,就是可见性问题和指令重排序导致的问题

Java 内存模型其实是一套规范,这套规范解决了可见性和有序性问题,能够使 JVM 按需禁用 CPU 缓存和禁止指令重排序。这套规范包括对 volatile、synchronized、final 三个关键字的解析,和 7 个Happens-Before 规则

JMM 通过添加内存屏障来禁止指令重排序,编译器的内存屏障会告诉编译器,不要对指令进行重排序,当编译完成后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在

JMM通过 happens-before 关系向程序员提供跨线程的内存可见性保证。如果一个操作happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

5、happens-before

  1. 程序次序规则: 在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  2. 管程锁定规则: 一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序。
  3. volatile变量规则: 对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后顺序。
  4. 线程启动规则: Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则: 线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  6. 线程中断规则: 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则: 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  8. Happens-Before的1个特性:传递性。

6、并发编程三大核心问题

  1. 可见性(缓存导致):多核系统每个 CPU 自带高速缓存,彼此间不交换信息(例子:两个线程对同一份实列变量count累加,结果可能不等于累加之和,因为线程将内存值载入各自的缓存中,之后的累加操作基于缓存值进行,并不是累加一次往内存回写一次)
  2. 原子性(线程切换带来):CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,高级语言的一条语句往往需要多条 CPU 指令完成,而操作系统做线程切换,可以发生在任何一条 CPU 指令执行完(例子:AB两个线程同时进行count+=1,由于+=操作是3步指令①从内存加载②+1操作③回写到主内,线程A对其进行了①②操作后,切换到B线程,B线程进行了①②③,这时内存值是1,然后再切到A执行③操作,这时的值也还是1,PS:这貌似也存在可见性的问题)
  3. 有序性(编译优化指令重排序导致):编译器编译程序时会优化 CPU 指令执行次序,使得缓存能够得到更加合理的使用。编译器为了优化性能,有时候会改变程序中语句的先后顺序,编译器调整了语句的顺序,有可能不影响程序的最终结果,但也有可能导致意想不到的结果。(例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”)

二、 基本操作

1、创建线程的方式

  1. 继承Thread类

    //构造方法的参数是给线程指定名字
    Thread t = new Thread("t1") {
        
        @Override
        //run方法内实现了要执行的任务
        public void run() {
            
            System.out.printLn("hello");
            
        }
        
    };
    
    t1.start();
    

    原理:创建了一个Thread子类对象,在子类中重写了Thread父类中的run()方法,最终执行的是子类中的run()方法

  2. 实现Runnable接口

     public void test2() {
    
            Runnable r = new Runnable() {
                @Override
                public void run() {
                    System.out.println("running");
                }
            };
    
            Thread t = new Thread(r, "t1");
    
            t.start();
    
        }
    

    原理:将Runnable对象当作参数传给Thread类的构造方法,通过init()方法将Runnable对象赋值给了一个Thread类中的一个成员变量,然后调用这个变量的run方法,相当于是调用了你实现Runnable接口时重写的run()方法

  3. 通过Future Task创建线程

    FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况

     	@Test
        public void test3() throws ExecutionException, InterruptedException {
    
            FutureTask<Integer> futureTask = new FutureTask<>(new Callable<Integer>() {
                @Override
                public Integer call() throws Exception {
                    System.out.println("running");
                    Thread.sleep(1000);
                    return 100;
                }
            });
    
            Thread t = new Thread(futureTask);
    
            t.start();
    
            //主线程阻塞,一指等到结果返回
            System.out.println(futureTask.get());
    
        }
    

    原理:FutureTask类实现了RunnableFuture接口,间接实现了Runnable, Future接口,所以FutureTask也可以作为一个任务对象。Future接口中的get方法用来返回任务的执行结果

submit相当于把任务包装了一下,submit会把Runnable或者Callable全部包装成FutureTask,submit方法提交过后会返回一个Future的句柄,这个句柄其实就是FutureTask对象;任务都有执行结果,通过submit拿到这个Future句柄之后,外部的线程就可以调用Future句柄的get方法,来获取结果;Future的get方法会阻塞获取结果的这个线程,直到FutureTask真正的被执行完毕后,阻塞到可以拿到结果为止

  1. 通过线程池创建线程

2、Callable 和 Runnable

4、Thread类常见方法

sleep

  • sleep()方法是Thread的静态方法,而wait是Object实例方法
  • wait()方法必须要在同步方法或者同步块中调用,也就是必须已经获得对象锁。而sleep()方法没有这个限制可以在任何地方种使用。另外,wait()方法会释放占有的对象锁,使得该线程进入等待池中,等待下一次获取资源。而sleep()方法只是会让出CPU并不会释放掉对象锁;
  • sleep()方法在休眠时间达到后如果再次获得CPU时间片就会继续执行,而wait()方法必须等待Object.notify/Object.notifyAll通知后,才会离开等待池,并且再次获得CPU时间片才会继续执行。

yield

join

如果一个线程实例A执行了threadB.join(),其含义是:当前线程A会等待threadB线程终止后threadA才会继续执行。

interrupt 系列

存在的意义:优雅的终止线程

stop方法可以让一个线程A终止掉另一个线程B

  • 终止的线程B会立即释放锁,这可能会让对象处于不一致的状态
  • 线程A也不知道线程B什么时候能够被终止掉,万一线程B还处理运行计算阶段,线程A调用stop方法将线程B终止,那就很无辜了~

Stop方法太暴力了,不安全,所以被设置过时了


使用的是interrupt来请求终止线

  • interrupt 不会真正停止 一个线程,它仅仅是给这个线程发了一个信号告诉它,它应该要结束了(明白这一点非常重要!)
  • Java设计者实际上是想线程自己来终止,通过上面的信号,就可以判断处理什么业务了。
  • 具体到底中断还是继续运行,应该由被通知的线程自己处理
  1. void interrupt()方法:中断线程,例如,当线程A运行时,线程B可以调用线程A的interrupt()方法来设置线程A的中断标志为true并立即返回。设置标志仅仅是设置标志,线程A实际并没有被中断,它会继续往下执行。如果线程A因为调用了wait系列函数、join方法或者sleep方法而被阻塞挂起,这时候若线程B调用了线程A的interrupt()方法,线程A会在调用这些方法的地方抛出 InterrputedException 异常而返回

  2. boolean isInterrupted()方法:检测当前线程是否被中断,如果是返回true,否则返回false

  3. boolean interrupted()方法:检测当前线程是否被中断,如果是返回true,否则返回false。与isInterrupted()不同的是,该方法如果发现当前线程被中断,则会清除中断标记,并且该方法时static方法,可以通过Thread类直接调用,另外从下面的代码也可以知道,在interrupted()内部是获取当前调用线程的中断标志而不是调用interrupted()方法的实例对象的中断标志。

5、Object类常见方法

wait

notify/notifyAll

三、并发关键字

1、synchronized

synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行

使用方式

  1. 修饰普通方法
  2. 修饰静态方法
  3. 修饰代码块

偏向锁

当我们使用synchronized关键字锁定某个对象的时候,如果这个对象处于匿名偏向状态(表示偏向线程的比特位为0,锁状态位表示的是偏向锁状态01),这个时候线程获取锁,直接在锁对象头中markword高位内存储当前线程内存地址,这步使用cas比较并替换完成,如果设置成功,这把锁就是偏向当前线程的锁,当前线程就可以直接执行同步代码块,获取锁的成本非常低,就两个步骤:1、向当前线程栈内添加一条锁记录,让锁记录中的锁标识指向当前锁对象,这步不存在并发问题,不使用cas。2、通过cas设置锁对象头的markword,存储当前线程地址,使用cas,可能有多个线程抢占锁。

偏向锁没有办法提供线程互斥,很多情况下使用synchronized确保线程安全,绝大多数情况下根本没有多线程场景,只有一条线程,如果没有偏向锁,使用轻量级锁,会有性能损耗。轻量级锁加解锁都要cas,而偏向锁解锁时不需要修改对象头的markword,少一次cas

某个线程拿到了偏向锁,执行完同步代码块后,会释放偏向锁吗? 退出代码块对应的字节码指令是 monitorexit,然后jvm处理这个指令时,首先会将当前线程栈内与当前锁对象相关的锁记录全部拿到,然后将最后一条锁记录释放掉,通过检查锁对象的markword,如果当前锁对象是偏向状态,就啥也不做了,完事了。就算线程退出了同步代码块,该锁对象仍然保留了偏向锁,好处在于下次获取成本会更低,只需要比对一下线程id,不用重新cas

偏向锁不一定会提升性能, 如果锁对象只被一个线程获取释放,偏向锁很有用。但是如果存在两个线程获取偏向锁,会出现锁升级的逻辑,会检查很多状态来保证锁的正确性,很麻烦

锁对象偏向了线程a,线程b如何获取这个锁呢(偏向锁撤销)(偏向锁升级为轻量级锁): 线程在执行monitorenter这条指令前,仍然是向当前线程栈内插入一条锁记录,锁记录的引用字段指向当前这个锁对象,然后检查当前锁状态,发现锁处于偏向锁状态,并且偏向的线程也不是当前线程,当前线程会提交一个撤销锁的任务,这个任务到提交到了vm线程的任务队列中,vm线程会在后台不停的处理任务队列内的任务,vm取一个任务之后,会看当前任务是否需要在safepoint状态下执行,如果需要在安全点时执行,则等到安全点时执行 (安全点,jvm出现这个状态的时候,所有的线程都处于阻塞状态,不能再向下执行程序了,线程的栈和贡共享的堆都处于冻结状态,只有后台的vm线程处于运行状态,vm线程可以执行一些特殊的任务,比如说full GC、撤销偏向锁。) 因为撤销的过程中会修改持有锁的线程的栈数据,如果不在安全点执行的化话,会有并发问题。撤销偏向任务,首先得检查当前jvm所有存活线程,主要是检查当前持有偏向锁的线程是不是存活着,如果持有偏向锁的线程已经消亡了,直接将锁对象markword改为无锁状态;如果偏向线程依然存活,需要遍历偏向线程栈内的锁记录来判断偏向线程是否还处于同步代码块中,如果栈内有一条锁记录的锁引用指向当前锁对象,那么就说明偏向线程仍然处于同步代码块内,否则跳出同步代码块。如果跳出了同步代码块,和偏向线程消亡的逻辑一样,直接将锁对象markword改为无锁状态;如果仍处于同步代码块中,需要对偏向锁进行升级为轻量级锁,首先要遍历偏向线程的栈,找到锁记录指向当前线程的第一条记录(初次加锁的记录),修改这条锁记录的displaced字段的数据为无锁状态的markword值,锁完全释放的时候会使用这个值,然后修改锁对象的markword为轻量级锁状态,并且保留这条锁记录内存地址,其实就是持锁线程内存空间的一个位置,后面可以根据这个空间位置来判断是不是当前线程持锁,锁升级流程大概完了。升级完事后,外部线程会进行自旋检查,会默认自旋10次,如果10次之后,仍然没有拿到轻量级锁,就会再次触发锁升级,升级为重量级锁,利用锁的互斥性来保证线程安全

轻量级锁

轻量级锁仍然解决不了线程竞争的问题,不提供线程互斥。使用synchronized包裹的代码其实出现并发的概率并不高。一般都是线程交替运行同步代码块,而不是并行,这种情况下如果没有轻量级锁,带来的后果就是会直接创建重量级锁monitor对象,很耗费资源,应该等到真的有并行的时候再去创建。轻量级锁对应的场景就是”多个线程交替执行同步代码块“的逻辑,真实运行环境下不存在线程并行时使用的。

轻量级锁和偏向锁的区别在于:偏向锁的假定条件是只有一个指定的线程获取锁,轻量级锁的假定条件是多个线程交替去获取锁

从无锁到轻量级锁的加锁逻辑(轻量级锁加锁逻辑):加锁对应的字节码是monitorenter指令,jvm执行这个指令之前第一件事是向当前线程栈内插入一条锁记录,锁记录内的锁应用字段保存锁对象地址;第二件事是让当前锁对象生成一条无锁状态的markword值(displaymarkword),并且让这个值保存到当前这条锁记录的displaced字段内;第三件事,使用cas的方式设置当前锁对象的markword值,修改为当前线程持有轻量级锁状态,如果当前对象的markword就是无锁状态,那么这一步就一定会修改成功,修改成功之后,当前锁状态就会从无锁变成轻量级锁,并且当前锁对象的markword中可以看出持锁线程是当前线程

锁重入:当前线程再次使用synchronized去锁定这把锁,从jvm角度去看,都做了什么:首先jvm解析器收到的字节码指令,仍然是monitorenter指令,在解析执行这个指令之前,会向当前线程栈内插入一条锁记录,并且锁记录内的锁引用字段仍然指向这个锁对象;第二件事,还是会让锁对象生成一条无锁状态的markword值叫做displaedmarkword,并且让线程栈内保存这个displayedMarkword值,到锁记录的displaced字段内;第三件事,还是使用cas的方式去设置当前锁的markword值,因为当前锁对象的markword处于轻量级锁状态,所以这一步cas会失败,失败之后,会检查为什么失败,首先就会检查重入这种可能,如果锁对象markdown内表示锁持有者的bit位的值,指向当前线程空间,就可以发现是所重如,然后把插入的dispalyedmarkword置为空就可以了。锁重入次数是靠当前线程栈内指向当前锁的锁记录来完成的,当前线程每重入一次锁,就会在这个线程内插入一条关于这个锁的锁记录

轻量级锁释放锁的逻辑

  1. 重入的释放: 释放锁的字节码是monitorexit,jvm处理这条指令的时候,首先会从当前线程栈中找到最后一条锁引用字段指向当前锁对象的锁记录,并且将将这条锁记录的字段设置为null(这一步就是释放锁记录),这一步不需要cas,因为线程栈内的数据,不存在多线程访问场景(栈的封闭性),这一步之后,会检查这条锁记录的displaced字段是否有值,如果没值,说明这条锁记录是锁重入的时候存放的,这个时候就需要将这条锁记录释放掉,对应的逻辑是锁记录中的锁引用字段设置为null,这样就完成了一次锁退出;
  2. 不是重入的释放: 也会将当前线程栈中锁引用指向当前锁对象的这条锁记录的锁引用字段设置为null(释放锁记录),检查锁记录,因为轻量级锁或重量级锁第一次加锁的时候,在线程栈内插入的锁记录比较特殊,第一次加锁时的这条锁记录它保存了一个displacedMarkWord值,这个值表示的状态为无锁状态,接下来当前线程需要把锁记录内的displaced值通过cas的方式设置到当前锁对象的markword中,这一步设置成功之后,就相当于完全释放锁了,如果失败了当前锁可能已经被升级到重量级锁或者现在正处于膨胀状态中了,需要走重量级锁的退出逻辑

重量级锁

重量锁就是强制将并行的线程依次通过同步代码块的实现,其实重量级锁和AQS的实现很相似,都是遵循管程模型,其实就是ObjectMonitor,核心是3个队列和一个字段,两个等待队列,竞争队列和EntryList队列,和一个阻塞队列WaitSet,核心字段是当前持有锁的线程引用owner字段;竞争队列和EntryList这两个队列用来存放等待获取锁资源线程的;WaitSet用来处理持锁线程调用锁对象wait()方法使用的,持锁线程调用了锁对象的wait()方法后,会把自己封装成一个Waiter节点插入到WaitSet中,然后会唤醒一个等待节点,接着这个执行wait操作的线程就会使用park操作将自己挂起,释放锁,直到其他线程通过使用notify或者notifyall操作,将它从WaitSet移动到竞争队列,竞争队列和EntryList队列里面的线程最终会被持锁线程释放锁时选择一个唤醒,去抢占锁

线程获取锁的流程(重量级锁加锁逻辑):如果是重量级锁的话,锁对象的markword保存的数据是管程对象内存位置和重量级锁状态,通过markword保存的重量级锁管程对象地址,抢占锁的线程到管程内去抢占锁,线程进入该管程之后,首先通过自旋的形式进行几次尝试获取锁,其实就是通过CAS将管程内的owner字段设置为当前线程,如果这个cas设置owner为自身线程成功的话,说明锁抢占成功了,直接返回就行了,如果不成功,说明这个锁有其它线程占用着呢,这个时候,这个线程就会把自己封装成一个waiter节点插入到竞争队列中,插入成功后,这个线程就把自己挂起,一直等到其他线程唤醒为止

重量级锁释放:重量级锁的释放对应的操作就是将管程对象的objectmonitor的owner字段设置为null,释放完锁之后,要检查竞争队列和EntryList,看看是否有等待锁的线程,如果有的话,它会根据不同的策略选择唤醒一个线程去抢占这把锁

重量级锁不是公平锁:因为持锁的线程释放掉锁之后,去竞争队列和entrylist队列内唤醒下一个继承者之前,这个窗口期间,外部再来线程是可以尝试获取锁的

轻量级锁膨胀为重量机级锁的逻辑:(3种情况)

  1. 第一种情况:有线程调用轻量级锁或者偏向锁对象的hashcode方法,因为锁对象处于轻量级或者偏向状态时,markword时没办法存储hash值的,这个时候会膨胀为重量级锁,然后重量级锁可以提供一些空间来存储hash值
  2. 第二种情况:持锁的线程调用锁对象的wait方法会导致膨胀,因为锁对象处于其他状态时是没有管程对象存在的,没有管程存在,就没有地方存放线程节点
  3. 当前锁处于轻量级锁状态,然后其他线程竞争锁时,真正的产生并发了,这个时候会膨胀成重量级锁

第三种情况(轻量级锁升级为重量级锁):首先因为竞争线程获取锁失败,会走上锁膨胀逻辑,在锁膨胀的逻辑中,会判断出当前锁对象是轻量级锁状态,这个线程会获取一个空闲的管程对象,然后通过cas修改锁对象状态为膨胀中,如果cas失败。说明有其他线程正在膨胀这个锁或者已经膨胀结束了,再次自旋获取就可以了;如果cas操作成功,那么当前线程需要给这个锁做升级操作,主要是将管程对象的owner设置为原轻量级锁持有线程,再将持有锁线程的第一条锁记录内存储的displacedMarkWord保存到管程对象内(栈顶),因为可能锁降级会用到,然后设置这个锁对象的markword为重量级锁,包括两个信息,一个是重量级锁对象内存位置,另一个是重量级锁状态;再之后,其他线程会再去请求获取这把锁,就都会找到这个重量级锁对象,走管程内逻辑

锁升级

当我们新创建一个对象的时候,它的对象头里面有25位的hashcode,4位的分代年龄,1位的偏向锁标记位,还有2位的锁标记位。

  • 如果我们仅仅new了一个对象,并没有通过显示或隐式调用object里面的hashcode方法,那么这个对象的对象头中的hashcode并没有真实存在(0占位;显式调用:调用object的hashcode;隐式调用:存放到类哈希结构中;调用对象复写的hashcode也不会生成)。
  • 对于分段年龄,新创建的对象其实无所谓,用0来表示。
  • 无锁状态和偏向锁状态它们的锁标志位都以01代表,但是,区别在于,无锁状状态下,偏向锁标志位是0,偏向锁状态下,偏向锁标志位是1。
  • 无锁状状态升级为偏向锁:首先,前提条件是无锁状态的对象里面的mark word里面没有真正的存放hashcode,如果隐式或显式调用了hashcode,那么这个对象即使开启了偏向锁配置,也无济于事。因为我们要将线程id放到hashcode的前23位,后2位放epoch,用这25位覆盖原来hashcode的25位,但这个覆盖操作不会对hashcode进行保存,所以有hashcode之后,不能进行覆盖,这种情况下,这个对象是不能用于偏向锁这个形式的
  • 如果这个对象创建之后,没有显式或隐式调用object类的hashcode方法,那么这个对象就可以加偏向锁,偏向锁的好处是,比如一个线程a再次对这个对象加锁,它就直接来检查一个这个对象里的线程id,是不是偏向自己就可以了,直接就进行了一次加锁的操作,这里有一个非常大的性能提升,仅需要简单的判断,就可以进行加锁
  • 偏向锁升级为轻量级锁,比如说线程a将自己的线程id放入了mark word中,这个时候来了一个线程b,想对这个对象进行偏向锁的加锁操作,这个时候b会将检查偏向锁标记位,为1,b就知道当前对象已经有线程偏向了。b会检查a的存活状态,如果a已经在代码块外了,b会将偏向锁标记位置为无锁状态,准备进行争抢,如果争抢成功,会将自己的线程id放进去;假如又来了一个线程c导致线程b竞争失败,这个对象偏向c,b会进行偏向锁撤销,当c进入jvm安全点,线程c的栈会被进行遍历锁记录,这时有三种情况:第一种情况,直接将对象设置为无锁状态,同时将锁标记位设置为00轻量级锁;第二种情况,将对象重新偏向为b;第三种情况,直接将该对象置为不可使用偏向锁。当第一种情况,一个线程b竞争到了这个轻量级锁,b会把除了锁标记位的30位赋值到a线程栈帧种的lock record中,将对象头中的这30位用来记录指向lock reocord的位置,释放锁的时候,会采用cas操作把这个30位信息再放回去

第一种情况:从偏向锁升级为轻量级锁,把锁标记位置为00,将线程id和epoch置为空
第二种情况:批量重偏向,阈值20
第三种情况:批量撤销,阈值40

  • 如果这个时候又来了一个线程来竞争这个轻量级锁,会采用cas操作尝试将对象头中的那30位赋值到自己的lock record中,但肯定会失败,因为mark word中的那30位已经变成了指针。默认情况下会进行10次cas循环,如果10次后还失败,升级为重量级锁
  • 十次cas失败后,进行锁升级,将30位轻量级锁指针,替换为重量级锁指针,指向object monitor,这个时候,持有轻量级锁的线程释放锁时会走重量级锁退出逻辑(cas不回去了),将自己lock record 中的信息放入object monitor的header中,并且将object monitor中的owner设置为自己,保证mark word中30位信息不丢失

2、volatile

1、保证可见性

可见性的意思是让其他线程可见,现在的处理器都是多核处理器,多核处理器就有多个CPU,每个CPU理论上可以提供一个线程,如果多个CPU同时对一个volatile修饰的int变量进行修改的时候,一旦其中一个CPU拿到了修改权限,对这个变量进行修改之后,它会立即通过总线将这个修改的值推送到主存,在推送的这个过程中,其他的CPU一直在嗅探总线的数据流通,在缓存一致性协议(MESI)的保证下,其他CPU可以嗅探到这条数据的修改,如果其他CPU的缓存行中有这条数据,将这条数据置为不可用。如果下次有线程对这条数据进行读或者写,需要先从主存中拉取这条数据,保存到自己的缓存行中,然后再进行处理。
CPU在嗅探总线的过程中,怎么判断这个变量就是volatile修饰的呢?当这个变量被volatile修饰,这个变量被一个线程修改,推送到总线的时候,会在这个变量的汇编代码中加一个lock指令,这个指令有两个含义:1、

2、禁止指令重排序

3、fianl

三、原子类

1、CAS

1、定义

Compare And Swap,是一种无锁算法,即不使用锁的的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量同步,所以也叫非阻塞同步

CAS 操作包含三个操作数:需要读写的内存值 V、进行比较的值 A、拟写入的新值 B,当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断地重试。

2、源码

原子变量类就是使用了 CAS 来实现的

我们以 AtomicInteger 类为例

 	/**
     * 
     * 如果当前值 == 预期值,则以原子方式将值设置为给定的更新值
     *
     * @param expect 期望值
     * @param update 更新值
     * @return 如果成功,返回 true;如果失败,返回 false,说明实际值不等于预期值
     */
	public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

这个方法中调用了 JDK 提供的 CAS 支持

  public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

  public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

  public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

CAS 通过调用 JNI 的代码实现,这三个方法就是借助 C 语言来调用 CPU 底层指令实现的,最终映射到的 CPU 上的一条汇编指令为 “cmpxchg”,这是一个原子指令,CPU 执行此命令时,实现比较并替换的操作

3、存在的问题

1、ABA 问题:

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被改变吗?不能,因为在这段时间它的值可能被改为其他值,然后又改为 A,那 CAS 就会误以为它从来没有被修改过。

解决方案:

Java 中使用 AtomicStampedReference 解决 ABA 问题,AtomicStampedReference 主要包含一个对象引用及一个可以自动更新版本号 “stamp” 的 pair 对象来解决 ABA 问题

//AtomicStampedReference 中有一个 pair 内部类,pair 里面提供了两个属性:数据引用和版本号
//AtomicStampedReference 类中 compareAndSet 方法,不仅要求期望引用和当前引用一致,还会对版本号进行对比
private static class Pair<T> {
        final T reference;	//数据引用
        final int stamp;	//版本号
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }

2、循环时间长开销大:

CAS在并发量小的情况下,性能不错,但是当并发量很大时,性能会差很多;CAS首先会比较期望值,如果这个期望值进和内存的实际值是一致的,再执行替换操作,CAS调用的是Java的Unsafe包下的native方法,反映到内核层面,映射到CPU上其实就是compare x change指令,这个指令在执行的时候会检查当前平台是否为多核平台,如果是多核的话,这个指令会通过锁总线的形式来保证同一时刻只能有一个CPU去执行,如果是100个线程同时让AtomicLong去自增,其实反映到平台上仍然是串行通过;而且更烦人的是,后面的线程,因为拿到的期望值都已经属于过期数据了,与实际内存的值不一致,后面的线程就全部失败了,失败之后,会再去读内存里的最新值作为期望值,再尝试修改知道成功为止,非常浪费CPU资源
自旋 CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给 CPU 带来非常大的执行开销。

2、LongAdder

采用空间换时间的思路,一般碰到热点数据的问题,处理思路的差不多,就是把热数据拆开,比如说原来每个请求都打到一个点上,现在拆分成几个点,这样的话冲突概率就小了,性能就提升了

LongAdder有两个核心字段,一个是long类型的base字段,一个是Cell数组,Cell结构里有一个long类型的value字段,使用LongAdder的过程中,如果从来没有产生过并发失败,数据会全部累加到base上,这个时候是采用CAS的方式去更新base字段;当某个线程与其他线程产生了冲突,CAS修改base字段失败的时候,就将cell数组构建出来,再往后所有的累加请求,就不再首选base字段了,而是根据分配给线程的哈希值,进行一个位移运算,找到对应的一个cell,将累加的值,通过CAS的方式写入到对应的cell中;

四、AQS、Lock、Condition

1、AQS

为什么说 AQS 这么重要呢,就是因为我们实现一把互斥锁需要大概4个条件:

  1. 需要一个 state 变量,来标记该锁的状态。state 至少有两个值:0 和 1。需要用 CAS 来对 state 进行操作,确保线程安全
  2. 需要记录当前是哪个线程持有锁
  3. 需要底层支持对一个线程进行阻塞和唤醒操作
  4. 需要有一个队列维护所有阻塞的线程,这个队列必须是线程安全的,采用 CAS 进行操作

AQS 满足了其中的三个,配合LockSupport工具类,就可以来实现一把互斥锁,怎么说吧,AQS 其实就是语言层面的 ObjectMonitor;就相当于说是把 synchronized 重量级锁复制到了语言层面;包括同步加锁,等待通知机制都在语言层面进行了实现;甚至就是说我们通过 AQS 实现的 ReentrantLock 会 synchronized 更加的灵活一点,在语言层面显式的进行加锁,还支持超时和重试机制,如果我们用 ReentrantLock,可以最大程度的避免死锁,synchronized形成死锁,它是没有办法自动破解的,如果我们用 ReentrantLock,我们使用Nanos 去设置一个超时时间,这样的话基本上就可以保证不会出现死锁

AQS 里面使用的是模板方法模式,对于扩展的方法,让实现者自己去是实现,对固有的方法,用AQS自己实现的方法,就比如说 ReentrantLock 就是复写了 AQS 的 tryAcquire 和 tryRealse 方法,如果我们自己想要实现一把锁,也需要就说复写模板方法

我举个例子:ReentrantLock 的调用链
调用lock方法之后,其实其实是进ReentrantLock的一个unfairsync内部类,上来就开始cas去抢锁,如果抢不到的话,就调用acquire方法,然后又回到自己复写的非公平的tryAcquire方法上,去cas抢锁,如果不成功的话,就调用AQS的addwaiter方法进入阻塞队列

2、Lock

Lock 是一个接口,

public interface Lock {
    void lock();	//不能被中断
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();    
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;	//可以被中断
    void unlock();
    Condition newCondition();
}

3、ReentrantLock

ReentrantLock 本身没有代码逻辑,实现都在内部类 Sync 中

2、锁的公平性和非公平性

Sync 是一个抽象类,它有两个子类,FairSync 和 NonfairSync,分别对应公平锁和非公平锁

  • 公平锁:需要排队,先来后到
  • 非公平锁:不需要排队,直接去抢锁

ReentrantLock 默认是非公平锁,可以通过传入一个布尔类型的参数来指定公平和非公平

3、锁实现的基本原理

互斥锁具备 synchronized 的功能,可以阻塞一个线程,为了实现一把具有阻塞和唤醒功能的锁,需要

  1. 需要一个 state 变量,来标记该锁的状态。state 至少有两个值:0 和 1。需要用 CAS 来对 state 进行操作,确保线程安全
  2. 需要记录当前是哪个线程持有锁
  3. 需要底层支持对一个线程进行阻塞和唤醒操作
  4. 需要有一个队列维护所有阻塞的线程,这个队列必须是线程安全的,采用 CAS 进行操作
AbtractOwnableSynchronizer(AOS)

AOS 是 AQS(队列同步器)的父类

互斥锁的第二个要素,在 AOS 中实现

public abstract class AbstractOwnableSynchronizer
    implements java.io.Serializable {
    ……
    private transient Thread exclusiveOwnerThread;	//记录锁被哪个线程持有
    ……
}
AbstractQueuedSynchronizer(AQS)

AQS 队列同步器,实现了互斥锁的第一个要素和第四个要素

第一个要素:

public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer {
    ……
    private volatile int state;	//记录锁的状态,通过 CAS	修改 state 的值
    ……
}

state 的值不仅可以是 0 或 1,还可以大于 1,原因是为了支持锁的可重入性

  • state == 0,没有线程持有锁,exclusiveOwnerThread == null
  • state == 1,有一个线程持有了锁,exclusiveOwnerThread == 该线程
  • state > 1,说明该线程重入了该锁

第四个要素:

在 AQS 中利用双向链表和 CAS 实现了一个阻塞队列,阻塞队列是 AQS 的核心

public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer {
    ……
    static final class Node {
      
        volatile Node prev;

        volatile Node next;
       
        volatile Thread thread;	//每个Node对应一个被阻塞的线程

    }
    ……
    private transient volatile Node head;	//指向双链表头部
    
    private transient volatile Node tail;	//指向双链表尾部
    
    private volatile int state;	//记录锁的状态,通过 CAS	修改 state 的值
    ……
}
  • 在初始化的时候,会建立一个空的 Node,让 head 和 tail 都指向这个空 Node,之后在后面加入被阻塞的线程对象,当 head = tail 的时候,说明队列为空
  • 入队就是把新的 Node 加到 tail 的后面,然后对 tail 进行 CAS 操作
  • 出队就是对 head 进行 CAS 操作,把 head 向后移动一个位置
Unsafe 和 LockSupport工具类

关于第三个要素,Unsafe 类中提供了阻塞或唤醒线程的一对操作原语:park/unpark

public native void unpark(Object var1);

public native void park(boolean var1, long var2);

locks 包下的工具类 LockSupport 对这一对原语进行了封装

public class LockSupport {
	……
	public static void park() {
        UNSAFE.park(false, 0L);
    }
    ……
    public static void unpark(Thread thread) {
        if (thread != null)
            UNSAFE.unpark(thread);
    }
	……
}
  • park():在当前线程中调用 park(),该线程就会被阻塞
  • unpark(Thread thread):在另外一个线程中调用 unpark,传入一个被阻塞的线程,就可以精准唤醒阻塞在 park()地方的线程

4、公平与非公平的lock()的实现差异

非公平锁
static final class NonfairSync extends Sync {
		……
        final void lock() {
            if (compareAndSetState(0, 1))	//一上来就尝试修改 state 的值,也就是抢锁,不考虑队列中有没有其他线程在排队,是非公平的
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }
        ……
    }
公平锁
static final class FairSync extends Sync {
      	……
        final void lock() {
            acquire(1);		//没有一上来就抢锁,在这个函数内步排队,是公平的
        }
        ……
}        
acquire()方法(获得)

acquire()方法是 AQS 的模板方法

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

调用 acquire()方法,实质上就是想调用 tryAcquire()方法,这个方法被 NonfairSync 和 FairSync 分别进行了各自的实现,如果调用 tryAcquire()失败,就会把当前线程放入阻塞队列,阻塞该线程

tryAcquire()(尝试获得)
  • 非公平锁实现:如果无人持有锁(state == 0),就开始抢锁,如果抢到锁,就记录是当前线程拿到了锁;如果是重入这个锁,就对 state 进行累加
  • 公平锁实现:只有排在队列第一个时,才去抢锁,否则继续排队

5、阻塞队列与唤醒机制(lock方法的实现)

public final void acquire(int arg) {
        //tryAcquire():true,获取锁成功 false:获取锁失败
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

当一个线程调用 lock()方法后,会进入 AQS 的 acquire()方法,acquire()方法内部会调用被两个内部类重写的 tryAcquire()去获取锁,当获取锁失败之后,会再次回到 AQS 的 acquire()方法中,调用 acquireQueued()方法,在这个方法内部会调用 addWaiter()方法

addWaiter()

addWaiter()方法,会为当前线程生成一个 Node 节点,,然后采用 CAS 的方式把 Node 放入双向链表的尾部

acquireQueued()

acquireQueued()方法内部是一个自旋,在这个自旋中,首先会判断自己是否排在队列头部,如果排在队列头部,那么会尝试获取锁。如果没有排在头部,那么就会自己调用 park()阻塞自己

为什么 park()可以被中断,而 lock()不可以被中断

因为 acquireQueued()内部是一个自旋,当线程 park()被中断唤醒之后,处于一个死循环中,会判断自己是否排在队列头部,如果排在队列头部,那么会尝试获取锁。如果没有排在头部,那么就会自己调用 park()阻塞自己

6、unlock()方法的实现

unlock 不区分公平还是非公平

unlock()方法的内部其实是调用了 release()方法,release()方法内部做了两件事:

  • 调用 tryRelease()函数释放锁
  • 调用 unparkSuccessor()函数唤醒队列中的后继者

7、tryLock()方法的实现

public boolean tryLock() {
        return sync.nonfairTryAcquire(1);
    }

tryLock()实现基于调用非公平锁的 tryAcquire(),对 state 进行 CAS 操作,如果操作成功就拿到锁;如果操作不成功则直接返回 fasle,也不阻塞

4、ReentrantReadWriteLock

读写锁就是读线程和读线程之间可以不用互斥了

1、类继承层次

ReadWriteLock

ReadWriteLock 是一个接口,内部由两个 Lock 接口组成

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}
ReentrantReadWriteLock

ReentrantReadWriteLock 实现了 ReadWrite 接口,当使用 ReentrantReadWriteLock 时,并不是直接使用,而是获得其内部的读锁和写锁,然后分别调用 lock/unlock

2、读写锁实现原理

ReadLock 和 WriteLock 看似是两把锁,实际上只是同一把锁的两个视图而已。可以理解为是一把锁,线程分为两类:读线程和写线程。读线程和读线程之间不互斥(可以同时拿到这把锁),读线程和写线程互斥,写线程和写线程也互斥

readLock 和 writeLock 实际上公用一个 sync 对象。sync 对象同互斥锁一样分为公平和非公平两种策略,并继承 AQS

public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
}
  • 读写锁也是用 state 来表示锁状态的,把 state 变量拆成两半,低 16 位用来记录写锁,高 16 位用来记录读锁
    • 为什么用不用两个 int 类型的变量分别表示读锁和写锁的状态呢?因为无法用一次 CAS 同时操作两个 int 类型的变量
    • 当 state == 0 时,说明既没有持有读锁,也没有持有写锁
    • 当 state != 0 时,要么线程持有读锁,要么线程持有写锁。两者不能同时持有
    • 可以通过 sharedCount(state)和 exclusive(Count)来判断到底是读线程还是写线程持有了锁

3、AQS 的两对模板方法

acquire/release、acquireShared/releaseShared 是 AQS 的里面的两对模板方法。互斥锁和速写锁的写锁都是基于 acquire/release 模板方法来实现的。读写锁的读锁是基于 acquireShared/releaseShared 这对模板方法来实现的

acquire/release 会在方法内部调用 tryAcquire/tryRelease 方法,这些 tryAcquire/tryRelease 方法会被各种子类 Sync 实现;acquireShared/releaseShared 也是一样

4、公平与非公平读写锁实现逻辑

  • 对于公平:不论是读锁还是写锁,只要队列中有其他线程在排队(排队等读锁,或者排队等写锁),就不能直接去抢锁,要排在队列尾部
  • 对于非公平:
    • 对于写锁,写线程能抢锁,前提是 state = 0,只有在没有其他线程持有读锁或写锁的情况下,它才有机会去抢锁。或者 state != 0,但那个持有写锁的线程是它自己,再次重入
    • 对于读锁,因为读线程和读线程不互斥,所以当队列的第一个元素是写线程的时候。读线程要阻塞一下,不能直接去抢锁

5、Condition

Object 的 wait 和 notify/notifyAll 是对象监视器配合完成线程间的等待/通知机制,而 Condition 与 Lock 也可以配合完成等待通知机制,前者是 Java 底层级别的,后者是语言级别的,具有更高的可控制行和扩展性

两者的区别:

  1. Condition 能够支持不响应中断,而通过使用 Object 方式不支持
  2. Condition 能够支持多个等待队列(new 多个Condition对象),而 Object 方式只能支持一个
  3. Condition 能够支持超时时间设置,而 Object 不支持

1、Condition 和 Lock 的关系

Condition 本身也是一个接口,其功能和 wait/notify 相似

public interface Condition {
    void await() throws InterruptedException;	//当前线程加进入等待状态,如果其他线程调用condition的sigal或者sigalAll方法,并且当前线程获取锁从await返回,如果在等待状态中被中断会抛出
    void awaitUninterruptibly();	//
    void signal();	//唤醒一个等待在 condition 上的线程,将该线程从等待队列中转移到同步队列中如果在同步队列中,能够竞争到锁则可以从方法中返回
    void signalAll();	//唤醒所有等待在 condition 上的线程
}

Condition 必须和 Lock 一起使用,所有的 Condition 都是从 Lock 中构造出来的

2、Condition 实现原理

创建一个 condition 对象是通过 lock.newCondition(),这个方法会 new 出一个 ConditionObject 对象,这个对象内部维护了一个等待队列,所有调用 condition.await 方法的线程会加入到等待队列中,并且线程状态转换为等待状态

读写锁中的读锁是不支持 Condition 的,写锁和互斥锁都支持 Condition。虽然他们各自调用的是自己的内部类 Sync,但内部类 Sync 都继承自 AQS。sync.newCondition 最终都调用了 AQS 中的 newCondition

public abstract class AbstractQueuedSynchronizer
 	public class ConditionObject implements Condition, java.io.Serializable {
		//Condition 的所有实现,都在 ConditionObject 里面
        ……
        private transient Node firstWaiter;
        private transient Node lastWaiter;
        ……
	}  
}

每一个 Condition 对象上面,都阻塞了多个线程。因此在 ConditionObject 内部也有一个双向链表组成的队列

3、await()实现分析

当调用 condition.await()方法后会使得当前获取 lock 的线程进入到等待队列,如果该线程能够从 await()方法返回的话一定是该线程获取了与 condition 相关联的 lock

执行流程:

  1. 在调用 await()方法后,首先将当前线程封装成一个 Node 节点,然后尾插进等待队列。
  2. 然后调用 fullyRelease()方法,让当前线程释放锁
  3. 紧接着进入一个 while 循环中,调用 LockSupport 的 park()方法自己阻塞自己,在两种情况下会退出 while 循环
    • 当前线程被中断,会退出 while 循环
    • 调用 condition.signal/condition.signalAll 方法当前节点移动到了同步队列

4、awaitUninterruptibly()实现分析

awaitUninterruptibly()与 await()的区别在于,while 循环中的逻辑是,收到中断信号后,不会退出,继续循环

5、signal/signalAll()实现分析

调用 condition 的 signal 可以将等待队列中等待时见最长的节点移动

五、并发容器

1、ConcurrentHashMap

ConcurrentHashMap1.7 其实是对HashMap进行了一个嵌套,外层是一个默认长度是16的segment数组,内层就是一个HashMap,对于JDK1.7来说,它最高同时承载的并发量就是16,也就说,理想状态下,有16个线程分别访问16个segment不会发生阻塞,其实已经非常不错了,从源码层面的话,每一个Segment都继承了ReentrantLock,其实就是使用ReentrantLock对每一个Segment进行了加锁;

JDK8对ConcurrentHashMap进行了一个更加强力的优化,它采用synchronized进行加锁,synchronized加锁其实是对桶位上的链表和红黑树进行锁定,比如我们数组上有20个桶位,理想状态下,有20个线程对每一个桶位进行访问,也不会发生阻塞;本质上其实是对所的粒度的一个细化;从源码上来看的话,虽然是采用synchronized进行加锁,但是是通过cas来对ConcurrentHashMap来进行一个调控

1、JDK7 中的实现方式

在 JDK7 中,一个 ConcurrentHashMap 被拆分为多个子 HashMap,每一个子 HashMap 称作一个 Segment

每个 Segment 都继承自 ReentrantLock,Sgement 的数量等于锁的数量,这些锁彼此之间相互独立,称作分段锁

构造函数
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {}
  • concurrencyLevel:表示 Segment 数组的大小,这个值一旦在构造函数中设定,之后不能再扩容。为了提升 hash 的计算性能,Segment 数组的大小必须是 2 的倍数。如果不指定这个参数的大小,Segment 数组默认大小是 16
  • initialCapacity:是整个 ConcurrentHashMap 的初始大小,用 initialCapacity 除以 ssize,是每个 Segment 的初始大小,每个子 HashMap 数组的大小也是 2 的倍数
  • loadFactor:负载因子,传给了 Segment 内 部,当 Segment 的元素个数达到一定的阈值,进行 rehash。Segment 的个数不能变化,但是 Segment 的内部可以扩容
put()函数

第一步:

首先会调用 ConcurrentHashMap 的 put 函数,通过对 key 进行散列和位运算,找到对应的 Segment,如果这个 Segment 为空,则对其进行初始化,然后调用这个 Segment 的 put 函数。(在调用 ConcurrentHashMap put 函数的过程中,是没有加锁的,分段锁加在了 Segment 的 put 函数中)

第二步: 初始化,因为可能存在多个线程同时对一个 Segment 初始化,所以我们需要避免重复初始化

在对 Segment 初始化的过程中,我们为了防止重复初始化,我们要不断检查 Segment 是否为空,然后在最后执行一个 CAS 来保证只会进行一次初始化

第三步: 在进入 Segment 的 put 函数中,我们首先会试着 trylock()拿到锁,如果拿到锁,则执行添加流程,如果没有拿到锁,并不会立即阻塞,而是会进行自旋,如果自旋了一定的次数,还是没有拿到锁,再进行阻塞,并且,在自旋的过程中,会对 Segment 中的节点进行遍历,如果没有发现重复的节点,则会新建一个节点,为后面的插入节省时间。

第四步:(插入的流程)

当拿到锁之后,我们会进行插入操作,首先z会进行位运算来找到 Segment 中对应的桶位。

找到对应的桶位之后:

  • 如果这个桶位为空,直接把节点放进去
  • 如果这个桶位不为空,遍历整个链表,寻找是否有重复值
    • 如果有,根据传入的一个参数是 true 还是 false 来决定是否对 value 进行修改
    • 如果没有,把节点头查进链表

如果在 put 新节点的过程中,达到了扩容阈值,会将这个新节点传入扩容函数中,在扩容完成之后,把改节点加入新的 Hash 表

扩容

当达到扩容阈值之后,Segment 内部会进行扩容

get()函数

整个 get 过程就是两次 hash:

  • 第一次 hash,计算出所在的 Segment
  • 第二次 hash,找到 Segment 中对应的桶位,然后遍历该位置的链表

整个读的过程中没有加锁,而是利用了 UNSAFE 包下的函数

2、JDK7 和 JDK8 的区别

  • 使用红黑树:JDK8 当一个槽里有很多元素时,会将链表进行树化,其查询和更新速度会比链表快很多,Hash 冲突的问题得到了较好的解决
  • 加锁的粒度:JDK7 是对 Segment 加锁,而 JDK8 是对每个头节点分别加锁
  • 并发扩容:JDK 7 中,Segment 的个数不能变,但是每个 Segment 的内部是可以扩容的;JDK8 相当于只有一个 Segment,当一个线程进行扩容时,其他线程还要进行读写,过程会复杂很多

3、JDK8 中的实现方式

JDK8 的实现有很大变化,没有了分段锁,所有数据都放在一个大的 HashMap 中,对每一个头节点分别加锁,并引入了红黑树

如果头节点是 Node 类型,则尾随它的就是一个普通的链表;如果头节点是 TreeNode 类型的,它的后面就是一颗红黑树

链表和红黑树之间可以互相转换:初始的时候是链表,当链表中的元素超过某个阈值时,把链表转换位红黑树;当红黑树中的元素小于某个阈值时,再转换为链表

构造函数
    public ConcurrentHashMap(int initialCapacity) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException();
        int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
                   MAXIMUM_CAPACITY :
                   tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
        this.sizeCtl = cap;
    }
  • 变量 cap 是 Node 数组的长度,是根据传入的初始容量 initialCapacity 计算出一个合适的数组长度
  • 变量 sizeCtl 是用于控制在初始化或者并发扩容时候的线程数,其初始值被设置成 cap
初始化

构造函数中只会计算数组的大小,并没有对数组进行初始化。只有往里面放入元素时,会进行初始化。当多个线程同时放入元素时,要防止重复初始化

多个线程的竞争是通过对 sizeCtl 进行 CAS 操作实现的。如果某个线程成功地把 sizeCtl 的值设置为 -1,它就拥有了初始化的权力,进入初始化的代码块,等到初始化完成,再把 sizeCtl 设置回去;其他线程则一直执行 while 循环,自旋等待,直到数组不为 null,结束初始化,退出 while 循环

put()函数

第一步:

我们首先要检查一下数组是否为空,如果为空,需要进行初始化

**第二步: **

我们需要判断当前桶位是否为空,如果为空,说明该元素是当前桶位的第一个元素,直接新建一个节点,然后返回

第三步:

我们需要判断当前桶位是否在进行扩容,如果是,我们需要帮助其扩容

第四步:

排除了以上三种情况,开始进行插入数据逻辑:

  • 首先要使用 synchronized 关键字对当前桶位的头节点进行加锁
  • 然后通过头节点的类型,去判断当前桶位中是链表还是红黑树,然后插入数据
  • 如果桶位里是一个链表的话,会用一个变量记录链表个数,当个数超过 8 时,调用 treeifyBin()函数,把链表转换为红黑树
扩容

4、常见面试题

描述一下JDK1.8并发map存储数据的结构

JDK1.8 ConcurrentHashMap结构其实和普通的HashMap基本一致,还是数组+链表+红黑树,存储数据的单元还是Node结构,Node结构里有key有value还有指向下一个节点的next指针,还有hash值。

ConcurrentHashMap负载因子可以修改吗

HashMap负载因子可以修改,ConcurrentHashMap负载因子不可以修改,负载因子字段是final修饰的,值是固定的0.75

Node节点的hash字段一般情况下必须大于等于0,为什么

1、因为hash字段的负值有其他意义,ConcurrentHashMap在扩容的时候,会触发一个数据的迁移的过程,就是把原表的数据迁移到扩容后的表的逻辑,原来的表迁移完一个桶位,我们需要放一个FowardingNode标记节点,这个Node的hash值它固定是-1

2、在Java8的并发map中,红黑树由一个特殊的节点TreeBin结构来代理,它本身也是继承Node,它的hash值比较特殊,是固定值-2

并发map的sizeCtl字段
  1. sizeCtl == -1;表示当前并发map正在做初始化,并发map也是延迟初始化的,它需要确保在并条件下,这个散列表结构只能被创建一次;当多个线程都执行到 initTable 逻辑的时候,就会使用CAS的方式去修改sizeCtl的值,CAS采用的期望值是0(sizeCtl默认0),更新之后就是-1;当更新为-1后,CAS修改成功的线程才会去真正执行创建散列表的逻辑,并发环境下只会有一个线程成功;CAS失败的线程,因为当前这个散列表正在初始化中,还没有办法去进行写数据的操作,所以说CAS失败的线程,会进行一个自旋检查,检查这个table是否已经完成初始化了,每次自旋检查之后,会让线程短暂释放被它占用的CPU,让当前先线程重新去竞争CPU资源,把这个CPU资源让给更需要的线程去使用
  2. sizeCtl > 0;如果 sizeCtl>0,并且这个散列表已经初始化完了,这个时候,sizeCtl它表示下次触发扩容的阈值,比如说sizeCtl==12的话,当我们插入新的数据的时候,检查这个容量,发现它>=12,就会触发扩容操作了
  3. sizeCtl小于0但不等于-1;表示散列表正处于扩容状态,高16位表示扩容标识戳,低16位表示参与扩容工作的线程数+1;扩容标识戳非常特殊,它必须要保证每个线程计算出来的值是一致的,不然的话没有办法实现并发扩容,它要保证每个线程在扩容,散列表从小到大,每次翻倍计算出来的值是一致的,就每个线程计算的戳一致,能标记出是同一个小表到同一个大表的扩容;比如说,从16扩容到32,每个线程计算出来的扩容唯一标识戳,它们都是同一个值,这个戳跟扩容之前的表是有关的,在同一个条件下,不管哪个线程来了,它计算出来的戳是一致的,这个戳会进行位运算,算出来的值正好最高位是1,就表示sizeCtl是一个负数

并发map怎么保证线程写数据安全

并发map采用的方式是Synchronized锁这个桶的头节点来保证桶内的写操作是线程安全的,就是桶内是串行化的;当桶内是空的,没有头节点,没有数据,这个时候就会采用CAS的方式来实现线程安全,线程会使用CAS的方式向slot里面写头节点数据,成功的话就返回,失败的话说说明其他线程竞争到这个桶的位置了,当前线程只能重新执行写逻辑,再次路由到这个桶位的时候,这个桶位里面应该已经有值了,当前线程就会采用Synchronized锁这个桶内的头节点,来保证写线程安全,这样就可以确保桶内是串行的

并发map寻址算法

并发map的寻址算法和hashmap区别不大,首先也是拿到key的hashcode,然后进行一个扰动运算,让hashcode高16位和低16位进行一个异或运算,并且会将符号位强制设置位0,让整个hash值成为一个正数,高低位异或是因为大部分情况下数组并不会太长,寻址算法(table.length-1)& hash值,在这种情况下有效参与寻址算法位有限,异或之后,可以让高为数值也可以参与到寻址算法里,增强散列性;因为table.length一定是2的n次方,所以table.length-1转换成二进制一定是1111……这种数据,这种数与任何数值进行按位与运算的结果一定是>=0并且<=(table.length-1)

并发map如何统计当前散列表数据量

使用LongAdder来统计数据量,为什么不采用原子类,因为原子类是采用CAS实现的,CAS在并发量小的情况下,性能不错,但是当并发量很大时,性能会差很多;CAS首先会比较期望值,如果这个期望值进和内存的实际值是一致的,再执行替换操作,CAS调用的是Java的Unsafe包下的native方法,反映到内核层面,映射到CPU上其实就是compare x change指令,这个指令在执行的时候会检查当前平台是否为多核平台,如果是多核的话,这个指令会通过锁总线的形式来保证同一时刻只能有一个CPU去执行,如果是100个线程同时让AtomicLong去自增,其实反映到平台上仍然是串行通过;而且更烦人的是,后面的线程,因为拿到的期望值都已经属于过期数据了,与实际内存的值不一致,后面的线程就全部失败了,失败之后,会再去读内存里的最新值作为期望值,再尝试修改知道成功为止,非常浪费CPU资源

LongAdder如何解决大并发量下,性能依旧很好

采用空间换时间的思路,一般碰到热点数据的问题,处理思路的差不多,就是把热数据拆开,比如说原来每个请求都打到一个点上,现在拆分成几个点,这样的话冲突概率就小了,性能就提升了

LongAdder有两个核心字段,一个是long类型的base字段,一个是Cell数组,Cell结构里有一个long类型的value字段,使用LongAdder的过程中,如果从来没有产生过并发失败,数据会全部累加到base上,这个时候是采用CAS的方式去更新base字段;当某个线程与其他线程产生了冲突,CAS修改base字段失败的时候,就将cell数组构建出来,再往后所有的累加请求,就不再首选base字段了,而是根据分配给线程的哈希值,进行一个位移运算,找到对应的一个cell,将累加的值,通过CAS的方式写入到对应的cell中;

触发扩容的线程,执行的预处理工作都有哪些

首先第一件事,触发扩容条件的这个线程,需要修改sizeCtl,sizeCtl<0 的时候,有两种情况,一种情况是-1,表示有线程正在初始化这个散列表;还有一种情况是当前散列表正在扩容;当有线程触发扩容,那么这个线程就必须去修改sizeCtl了,根据扩容前散列表长度,计算出扩容唯一标识戳,是一个16位的值,并且最高位是1;sizeCtl的高16位存储扩容唯一标识戳,sizeCtl的低16位存储的值其实是参与扩容工作的线程数+1,因为咱们这个线程是触发扩容的线程,将sizeCtl低16位直接设置成2,表示有一个线程正在进行扩容工作了;

第二件事就是,这个线程需要创建一个新的table,大小事扩容前的两倍,并且需要告诉新表的引用地址到map.nextTable字段,因为后续的协助扩容的线程需要知道将老表的数据迁移到哪,再然后需要保存老表的长度到map.transferIndex字段,这个字段记录老表的总迁移进度,迁移工作是从高位桶开始,一直迁移到下标是0的桶,大概就是这样

迁移的时候,会对迁移的桶进行标记,会创建一个ForwardingNode对象,这个Node比较特殊,是用来指定这个桶位已经被迁移完毕了;ForwardingNode中还有一个指向新表的字段,它提供了一个查询方方法,当我们查询的时候呢,会碰到桶位它是ForwardingNode节点,现在我们要查询的数据已经被迁移到新表了,这个时候可以通过这个节点提供的方法重新定位到新表上进行查询

散列表正在扩容时,再来写请求如何处理

这个是要分情况的,如果这个写操作访问的桶位还没有被迁移,就直接拿桶的锁,然后进行正常的插入操作就行了;迁移桶位的时候也会加锁,这里其实是同步的,不存在并发问题

如果写操作访问的头节点正好是FWD节点,这就比较麻烦了,因为这个扩容的速度越越好,正好这个桶位在进行迁移,这个写操作的线程没有办法去写数据,就会让这个写数据的线程去帮助扩容,这个线程会根据全局transferIndex去规划当前任务区间,比如说,规划到[256,240],这些桶位归当前线程来搬运,当前线程就会将这些桶位的数据搬运到新的table中,搬运完之后,再根据全局transferIndex去分配下一批任务,直到线程再也分配不到任务的时候,此时扩容工作就基本结束了,当前线程就可以返回到写数据的逻辑里面了,最终数据会被写到新扩容后table中

因为我们维护了一个sizeCtl,它的低16位记录的是参与扩容的线程数+1,当写线程执行辅助扩容任务之前,会更新sizeCtl的低16位,让低16位+1,表示帮助扩容的线程多了一个,但是每个辅助扩容的线程最终都会因为分配不到扩容任务而退出扩容工作,在退出之前会给低16位-1;最后一个退出扩容任务的线程要进行收尾工作,它首先会判断sizeCtl低16位-1是否等于1,如果为1表示是最后一个线程,它在退出前会再检查一遍老表,看看有没有遗漏的桶位没迁移,判断条件是桶位中是不是fwd节点,如果是就跳过,如果不是,当前线程就迁移这个桶位的数据,相当于一种保障机制;最后将新表的引用保存到map.table字段上,再根据新表的大小算出下次的扩容阈值,保存到sizeCtl字段上

桶升级为红黑树,并且当前线红黑树上有读线程访问,再来写线程怎么办

这个时候不能写数据,因为红黑树比较特殊,写数据之后,可能会导致红黑树失衡,会触发自平衡操作,读线程在一颗正在发生结构变化的树上去查数据,肯定是不现实的

并发map中的内部类TreeBin中有一个int类型的state字段,每个读线程在读数据之前,都会使用CAS将state的值+4,读完数据之后,使用CAS -4,写线程进入红黑树之前,会见检查state字段,看看它是不是0,如果是0说明这棵树上没有读线程在访问数据,写线程就使用CAS的方式将state更新为1,表示加了写锁,加了写锁,其他线程再来的话,就不能直接访问红黑树结构了;如果发现不是0,就说明这棵树上有线程,需要进行等待,调用LockSupport的park()将自己挂起,并且告诉红黑树有线程在等待,就是把state的比特位第二位设置为1;当读线程结束的时候,会让state -4,减完之后,会检查state的值是不是2,因为之前把state的第二位设置为1,转换成十进制就是2,如果是的话就把写线程唤醒

红黑树在执行写操作的时候,此时再来读请求怎么办

TreeBin在设计的时候,就考虑了这些问题,TreeBin保留了两个数据结构,一个是红黑树,一个是链表,当这个state表示写锁状态时,对请求不能再进红黑树了,但是可以直接到链表结构上去访问数据

2、ThreadLocal

ThreadLocal 表示线程的本地变量,即每个线程都拥有该变量副本,达到人手一份的效果,各用各的这样就可以避免共享资源竞争,做数据隔离的

ThreadLocal 不是用来解决共享对象的多线程访问问题的,数据实质上是放在每个 thread 实例引用的 threadLocalMap,也就是说每个线程都用于属于自己的数据容器(threadLocalMap),彼此之间互不影响

1、核心数据结构 ThreadLocalMap(核心原理)

Thread 类中,有一个变量

ThreadLocal.ThreadLocalMap threadLocals = null;

每个线程被创建出来,都维护了一个 threadLocals 变量,当每个线程创建 ThreadLocal 的时候,实际上数据是存在自己线程的 threadLocals 变量里的 ThreadLocalMap 中的,别人没法拿到,从而实现了数据隔离

static class ThreadLocalMap {

        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
}

ThreadLocalMap 是一个比较特殊的 Map,内部维护了一个 Entry 类型的 table 数组,它的每个Entry的key都是一个弱引用,这样设计的好处是,如果这个变量不再被其他对象使用时,可以自动回收这个ThreadLocal对象,避免可能的内存泄露

ThreadLocalMap 中使用开放地址法来处理散列冲突,而 HashMap 中使用的分离链表法。之所以采用不同的方式主要是因为:在 ThreadLocalMap 中的散列值分散的十分均匀,很少会出现冲突。并且 ThreadLocalMap 经常需要清除无用的对象,使用纯数组更加方便。

开放定址法不会创建链表,当关键字散列到的数组单元已经被另外一个关键字占用的时候,就会尝试在数组中寻找其他的单元,直到找到一个空的单元。探测数组空单元的方式有很多,这里介绍一种最简单的 – 线性探测法。线性探测法就是从冲突的数组单元开始,依次往后搜索空单元,如果到数组尾部,再从头开始搜索(环形查找)

2、ThreadLocal 方法的实现原理

set 方法实现原理

set 方法设置在当前线程中 threadLocal 变量的值

通过当前线程对象 thread 获取该 thread 所维护的 threadLocalMap,若 threadLocalMap 不为 null,则以 threadLocal 实例为 key,值为 value 的键值对存入 threadLocalMap 中,若 threadLocalMap 为 null 话,就新建 threadLocalMap 然后在以 threadLocal 为键,值为 value 的键值对存入即可

  1. 首先会获取当前线程实例对象
  2. 通过当前线程实例获取到 ThreadLocalMap 对象
  3. 如果 Map 不为 null,则以当前 threadLocal 实例为 key,值为 value 进行存入
  4. 如果 Map 为 null,则新建 ThreadLocalMap 并存入 value
get 方法实现原理

get 方法是获取当前线程中 threadLocal 变量的值

通过当前线程 thread 实例获取到它所维护的 threadLocalMap,然后以当前 threadLocal 实例为 key 获取该 map 中的键值对(Entry),若 Entry 不为 null 则返回 Entry 的 value。如果获取 threadLocalMap 为 null 或者 Entry 为 null 的话,就以当前 threadLocal 为 Key,value 为 null 存入 map 后,并返回 null

remove 方法实现原理

先获取与当前线程相关联的 threadLocalMap 然后从 map 中删除 key 为该 threadLocal 实例的键值对

3、ThreadLocal 内存泄漏

出现原因

ThreadLocal 在保存的时候会把自己当作 key 存在 ThreadLocalMap 中,正常情况应该是 key 和 value 都应该被外界强引用才是,但是现在 key 被设计成了弱引用

弱引用:只具有弱引用的对象拥有更短暂的生命周期,在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间是否足够,都会回收它的内存

由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象

所以就会造成一个问题就是,key 是弱引用被回收了,可是 value 还在,这个 value 只有当线程被回收时才会被回收,否则只要线程不退出,value 总是会存在

线程池里面的线程,线程都是复用的,那么之前的线程实例处理完之后,出于复用的目的线程依然存活,所以,ThreadLocal 设定的 value 值被持有,导致内存泄露。

解决方案

当你不需要这个ThreadLocal变量时,主动调用 remove(),这样对整个系统是有好处的

4、InheritableThreadLocal

基本使用:

主线程开了一个子线程,但是我们希望在子线程中可以访问主线程中的 ThreadLocal 对象,也就是说有些数据需要进行父子线程间的传递,我们希望子线程可以看到父线程的 ThreadLocal,那么就可以使用 InheritableThreadLocal

InheritableThreadLocal threadLocal = new InheritableThreadLocal();
存在的问题:
  1. 变量的传递是发生在线程创建的时候,如果不是新建线程,而是用了线程池里的线程,就不灵了
  2. 变量的赋值就是从主线程的 map 复制到子线程,它们的value是同一个对象,如果这个对象本身不是线程安全的,那么就会有线程安全问题

六、线程池

1、ThreadPoolExecutor

1、实现原理

调用方不断地向线程池中提交任务;线程池中有一组线程,不断地从队列中取任务,这是一个典型的生产者——消费者模型

2、核心数据结构

public class ThreadPoolExecutor extends AbstractExecutorService {
	……
	private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));	//状态变量
    private final BlockingQueue<Runnable> workQueue;	//存放任务的阻塞队列
    private final ReentrantLock mainLock = new ReentrantLock();	//对线程池内部各种变量进行互斥访问控制
    private final HashSet<Worker> workers = new HashSet<Worker>();	//线程集合
	……
}
  • ctl:状态变量,高 3 位表示当前线程池运行状态,剩余 29 位表示当前线程池中所拥有的线程数量
  • workQueue:任务队列,当线程池中的线程达到核心线程数量时,再提交任务,就会直接提交到 workQueue
  • minLock:线程池全局锁,增加 worker ,减少 worker 时都需要持有 minLock,修改线程状态时也需要持有 minLock
  • workers:线程集合,线程池中真正存放线程的地方

每一个线程是一个 Worker 对象,Worker 是 ThreadPoolExecutor 的内部类

private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
    ……
	final Thread thread;	//Worker 封装的线程
    Runnable firstTask;		//Worker 接收到的第一个任务
    volatile long completedTasks;	//Worker 执行完毕的任务个数
    ……   
}

Worker 继承了 AQS,也就是说 Worker 本身就是一把锁,而且是独占锁

3、核心配置参数与处理流程

  • corePoolSize:在线程池中始终维护的线程个数
  • maxPoolSize:在 corePoolSize 已满、队列也满的情况下,扩充线程至此值
  • keepAliveTime/TimeUnit unit:maxPoolSize 中的空闲线程,销毁锁需要的时间。总线程数收缩回 corePoolSize
  • blockingQueue:线程池所用的队列类型
  • threadFactory:线程创建工厂,可以自定义,也有一个默认的
  • RejectExecutionHandler:corePoolSize已满,队列已满,maxPoolSize已满,最后的拒绝策略

在每次往线程池中提交任务的时候,有如下的处理流程:

  1. 判断当前线程数是否大于或等于 corePoolSize。如果小于,则新建线程执行;如果大于,进入2
  2. 判断队列是否已满。如未满,则放入;如已满,则进入3
  3. 判断当前线程数是否大于或等于 maxPoolSize。如果小于,则新建线程执行;如果大于,则进入4
  4. 根据拒绝策略,拒绝任务

4、关闭线程池

关闭一个线程池的时候,有的线程还在执行某个任务,有的调用者正在向线程池提供任务,并且队列中可能还有未执行的任务,因此线程池的关闭不是瞬间的,而是需要一个平滑的过度

4.1、线程池生命周期

线程池中一个 int 类型的 ctl 变量,最高的3位存储线程池状态,其余29位存储线程个数

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uvvSjUIl-1648625440719)(C:\Users\86151\Downloads\未命名文件 (1)].png)

线程池的状态迁移有一个特征:只会从小的状态值往大的状态值迁移,不会逆向迁移

线程池有两个关闭函数,shutdown()和 shutdownNow(),这两个函数会让线程池切换到不同的状态。在队列为空之后,进入 TIDYING 状态,在执行钩子函数 terminated()进入 TERMINATED 之后,线程池才会关闭

4.2、正确关闭线程池的步骤

首先调用 shutdown()或者 shutdownNow()方法,然后调循环调用 awaitTermination()方法,等待线程池真正终止

awaitTermination()方法中不断地循环判断线程池是否达到了最终状态 Terminated,如果是,就返回;如果不是,则通过 termination 条件变量阻塞一段时间,苏醒之后,继续判断

4.3、shutdown()和 shutdownNow()的区别
  1. 前者不会清空任务队列,会等所有的任务执行完成;后者会清空任务队列

    shutdownNow()方法内部会调用一个方法,这个方法会清空任务队列,然后一个 List,仔细观察可以发现,shutdownNow()方法的返回值是一个List,但是shutdown()方法的返回值是 void

  2. 前者只会中断空闲的线程,后者会中断所有线程

    线程池的核心数据是一个个的 worker,线程池中的线程其实就是 worker,这个 worker 继承了 AQS 的独占模式,其实就是一个独占锁,当它开始执行任务的时候,就会先加锁。所以我们就可以通过去拿一个 wokrer 的独占锁来判断这个线程是否空闲。

    shutdown()方法的内部调用了一个方法,这个方法会变量所有的 worker,尝试去trylock()拿这个 worker 的独占锁,如果拿到证明空闲,就会发译者中断信号,如果拿不到就不发

    shutdownNow()方法内部调用的方法,会向所有 worker 发送中断信号

    发送中断信号不代表打断线程的运行

shutdown()和 shutdownNow()都会调用一个 tryTerminate()方法,这个方法不会强行终止线程池,只是做了一下检测:当 workerCount 为 0,workQueue 为空时,先把状态切换到 TIDYING,然后调用钩子函数 terminated()。当钩子函数执行完成后,状态改为 Terminated,然后调用 termination.sinaglAll(),通知阻塞在 awaitTermination 的 所有调用者线程

5、任务的提交过程分析

5.1、excute()执行流程
  1. 首先根据 ctl 值判断当前线程是否小于 corePoolSize,如果小于,调用 addWorker 方法,开启新的线程,如果大于等于,进入2
  2. 通过调用 workerQueue.offer 将任务放入阻塞队列
  3. 如果阻塞队列已满,放入失败,我们调用 addWorker 尝试开启一个新的线程,如果开启失败,进入4
  4. 开启失败说明,当前线程数已经大于线程池最大容量,调用拒绝策略
5.2、addWorker()执行流程

addWorker 使用来开启一个新的线程的,根据参数 core 的不同,采取不同的上限值

  1. 首先我们进入自旋,然后在这个自旋中,我们主要去判断当前线程池的状态是否为 Running

  2. 然后我们又进入一个自旋,在这个自旋中,我们首先根据 ctl 值去判断线程池内的线程数是否超过阈值

    • 如果超过,返回 false
  3. 如果没超过,我们采用 CAS 的方式尝试让线程数加1,这里是先获取一个令牌,不是真正创建一个线程

    • 如果尝试失败,退到第一个自旋重新开始
  4. 如果获取令牌成功,开始执行真正创建线程的逻辑

  5. 首先我们把任务传进 woker 的构造方法创建一个 worker,在这一步相当于是创建了一个线程

    worker 的构造方法以这个任务为参数创建了一个 worker,并且把这个任务赋值给 worker的属性,firstTask

    然后 worker 的构造方法会使用线程工厂把 worker 自己作为参数传进去创建一个线程,在这个 worker 类中其实还有一个 run()方法,

    当线程启动时,就调用的是这个 run()方法

  6. 然后我们拿到全局锁 minLock,尝试将这个新创建的 worker 放入进 workers 中,然后尝试完毕后,把全局锁释放掉

    这个 workers 只一个set集合,是线程池中真正存放线程的地方

    • 如果成功,启动新创建的worker中的线程,其实就是调用worker的run()方法
    • 如果失败,把上面的加一减个一,就是把令牌还回去

6、任务的执行过程分析

任务的执行过程主要就是调用 worker 的 run()方法,在 worker 的 run()中调用了 ThreadPoolExcutor 的 runWorker 方法,把当前 worker 作为参数传进了 runWorker 中

7、submit和excute方法的区别

其实最终调用的方法都是excute方法,submit相当于把任务包装了一下,submit会把Runnable或者Callable全部包装成FutureTask,submit方法提交过后会返回一个Future的句柄,这个句柄其实就是FutureTask对象;任务都有执行结果,通过submit拿到这个Future句柄之后,外部的线程就可以调用Future句柄的get方法,来获取结果;Future的get方法会阻塞获取结果的这个线程,直到FutureTask真正的被执行完毕后,阻塞到可以拿到结果为止

8、Runnable接口没有返回值,但是FutureTask.get()方法是有返回值的,这块怎么处理

Runnable没有返回值,只有Callable才有返回值,其实在FutureTask内,就是把提交的Runnable使用的适配器模式转换为了Callable接口,但是Runnable转化的这个Callable接口它的返回值是null,反映到业务层面调用FutureTask的get方法会返回一个null值

2、ScheduledThreadPoolExecutor

3、FutureTask

提到FutureTask就不得不提到Runnable和Callable

Runnable和Callable

当我们使用线程来运行Runnable任务时,是不支持获取返回值的,因为Runnable接口的run()方法使用void修饰的,方法不支持返回值。但在很多情况下,我们需要获得任务的执行结果,这个时候使用Runnable显然就不可以了,Callable就出现了

Callable与Runnable类似,它是一个接口,也只有一个方法:call(),不同的是Callable的call()方法有是有返回值的

Runnable可以通过Thread来运行Runnable任务,而Callable通常需要结合线程池来使用,线程池中除了提供execute()方法来提交任务以外,还提供了submit()的三个重载方法来提交任务,这三个方法均有返回值。

在这里插入图片描述
这三个方法的返回值均是Future类型的对象

Future与FutureTask

当任务提交到线程池后,我们可能需要获取任务的返回值,或者想要知道任务有没有完成,或者想取消任务,这个时候就需要Fututre了

Future是一个接口,提供了5个方法,当一个任务通过submit()方法提交到线程池后,线程池会返回一个Future类型的对象,可以通过Future对象的这5个方法来获取任务在线程池中的状态

在这里插入图片描述
Future接口有一个具体的实现类:FutureTask,线程池的submit()返回的Future类型的对象,其实都是FutureTask;

FutureTask实现了Runnable接口和Future接口,所以FutureTask既是Runnable类型又是Future类型,当调用submit()方法来向线程池中提交任务时,无论提交的是Runnable类型的任务,还是Callable类型的任务,最终都是将任务封装成一个FutureTask对象

七、并发工具类

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值