并发编程

进程与线程

进程

  1. 当一个程序被运行,从磁盘加载这个程序的代码到内存,这就开启了一个进程

    进程是活动的,程序已经被CPU执行了,这就是进程

    程序是静态的,进程是动态的。

线程

  1. 一个进程之内可以分为一到多个线程,可以认为一个进程是由多个线程组成的。
  2. 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给CPU执行
  3. Java中,线程作为最小的调度单位,进程作为资源分配的最小单位

二者对比

  1. 进程基本上相互独立,而线程存在于进程内,是进程的一个子集

  2. 进程拥有共享的资源,供内部的线程共享

  3. 不同计算机之间的进程通信,比如客户端和服务器之间的通信,需要遵循共同的协议,如HTTP

    浏览器是一个进程,服务器是tomcat,他们之间的通信需要遵循http

  4. 上下文切换,就是一个任务暂停与继续的这个过程(一个任务从保存到再加载的过程)

并行与并发

  1. 单核CPU下,线程实际还是串行执行的,只是由于CPU在线程间的切换非常快,所以感觉是并行的,总结就是微观串行,宏观并行

    一般将这种线程轮流执行的,线程轮流使用CPU的做法称为并发

    image-20210904170008584

  2. CPU多核才可以并行

    image-20210904170145964

    每个核心都可以处理一个线程的指令,只要有多个核心,那么就可以同时执行。

  3. 更多的时候是既有并发也有并行

    线程数比核心数多的时候,就是并发和并行都存在

  4. 并不是多线程执行的效率一定比单线程高

    因为有上下文切换、死锁、资源限制

  5. 并发就是同一时间应对多件事情的能力

    并行就是同一时间动手做多件事情的能力

  6. 既有并发又有并行是最常见的场景

  7. 同步:

    从方法调用的角度

    • 如果需要等待结果返回,才能继续运行,就是同步
    • 不需要等待结果返回,就能继续运行,这是异步
  8. 多线程可以让方法执行变为异步的,不需要干等着上一个方法的结果返回

    多个线程同时执行,所以才有线程安全问题,线程之间相互抢占资源

  9. 比如在项目中,视频文件格式转换比较费时,这时新开一个线程处理视频转换,避免阻塞主线程。

  10. 多线程可以充分利用多核,做到并行执行!

    单核CPU同样可以执行多线程,这就是并发,多个线程同时抢占同一份CPU资源。

    image-20210904171814442

  11. 单核是并发,在微观上是串行执行的。

    单核CPU也可以多线程,但是微观上串行,宏观上并行,并不能提高效率,多核CPU才能提高效率,单核时仍然是轮流执行

    而且还可能有死锁、资源限制、上下文切换的影响,导致多线程的执行效率并不一定就比单线程高。

    单核CPU即使用了多线程也没有办法提高效率,因为在微观上是串行的。

    单核CPU用多线程反而比单线程慢,而不是执行时间相等,因为多线程涉及到上下文切换。

    总结:多核CPU用多线程可以做到并行,可以提高执行效率,而单核CPU执行多线程,在微观上串行,并且有上下文切换,反而不能提高执行效率

  12. 并不是说单核CPU下,多线程没有意义

    多核CPU可以并行跑多个线程,但能否提高效率还是要分情况

Java线程

创建线程

  1. 使用Runnable接口,对比直接继承Thread的方式,改变的地方是:把任务和线程分离了,而继承Thread的方式,声明Thread对象是和任务在一起的。

  2. 如果一个接口,只有一个抽象方法,那么可以用lambda表达式来简化,接口用FunctionalInterface来修饰,如果一个接口有多个抽象方法是没法用lambda表达式来简化的。

  3. 原理之Thread和Runnable的关系

    • Thread是把线程和任务合并在了一起

      Runnable是把线程和任务分开了

    • 用Runnable更容易与线程池等高级API配合

    • 用Runnable让任务脱离了Thread继承体系,没有单继承的局限性,更灵活

  4. 用FutureTask配合Callable接口和Thread的方式创建线程

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

  5. Callable接口和Runnable接口的差别:

    Callable里的call()方法可以抛出异常,而且比Runnable接口里的run()方法多了一个返回值

  6. 底层是多核来对多线程进行并行的处理还是由一个单核CPU来对多线程进行并发的执行,采用时间片轮转的方式,这是我们控制不了的,是由底层的任务调度器来决定的。

线程运行的原理

  1. JVM的虚拟机栈、本地方法栈和程序计数器是线程独有的

    虚拟机启动后,虚拟机就会为线程分配一块栈内存

  2. 每个栈由多个栈帧组成,对应着每次方法调用时所占用的内存

    一个栈帧对应着一个方法调用

    每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。

  3. 一个方法调用完毕后,即返回后,那个方法调用对应的栈帧就没有了,对应的栈帧的内存就被释放掉了,没有垃圾回收,但是存在栈溢出

    PC是两样都没有,而虚拟机栈和本地方法栈是存在栈溢出,但是没有垃圾回收,堆空间和方法区是存在OOM,也有垃圾回收机制

  4. 栈帧里有局部变量表,操作数栈,方法返回地址,动态链接,一些附加信息

  5. 每个线程拥有独立的虚拟机栈

线程的上下文切换

  1. 线程的上下文切换,是指一个线程暂停,然后恢复执行,这是一次上下文切换
  2. 以下一些原因触发线程的上下文切换(即线程暂停就会出现上下文切换):
    • 线程的CPU时间片用完
    • 垃圾回收(用户线程暂停(stw),垃圾回收线程工作)
    • 有更高优先级的线程需要运行
    • 线程自己调用了sleep 、wait、join、yield等方法
  3. sleep和wait方法都可以使调用方法的线程进入阻塞状态,但是sleep不会释放同步监视器锁,而wait会释放同步监视器锁,而且sleep可以在任何地方调用,但是wait方法只能在同步代码块内或同步方法内调用
  4. 当上下文切换发生时,操作系统需要保存当前线程的状态,并恢复另一个线程的状态,Java中对应的概念就是程序计数器,左边是指令地址(偏移地址),右边是字节码指令,作用就是记住下一条JVM需要执行的字节码指令的指令地址。
  5. 频繁的上下文切换会影响性能,而且考虑到死锁、资源限制,并不是线程数越多越好

start()与run()

  1. start()表示启动线程,run()表示线程启动后要执行的代码

    不能直接调用run(),因为线程没有启动,还是只有一个线程即主线程

    调用start()方法后,线程会进入就绪状态,等待CPU分配时间片即资源,就可以运行了,就绪状态和运行状态在Java里都称为RUNNABLE状态

  2. 直接调用run是在主线程中执行了run,没有启动新的线程

  3. 使用start是启动新的线程,通过新的线程间接执行run中的代码

sleep()与yield()

  1. 调用sleep会让当前线程从Runnable进入到Timed Waiting状态(阻塞,或者说一个有时限的等待过程)

  2. 其他线程可以使用interrupt方法打断正在睡眠的线程,这是睡眠的线程会抛出InterruptedException

  3. sleep()方法写在哪个线程中,就让哪个线程睡眠,对当前线程进行睡眠操作

  4. 睡眠结束后的线程未必会立刻得到执行,也要重新等待直到得到cpu时间片分配

  5. 调用yield会让当前线程从Running进入Runnable就绪状态,然后调度执行其他线程

    具体的实现依赖于操作系统的任务调度器

线程优先级

  1. 线程优先级会提示任务调度器优先调度该线程,但是它仅仅是一个提示,但是调度器可以忽略它。

    如果CPU比较忙,那么优先级较高的线程会获得更多的时间片,但CPU闲时,优先级几乎没有作用

  2. sleep和wait的差别

    • sleep()方法适用于无需同步锁的场景,可以应用在任何地方,而wait()则需要在同步方法或同步代码块中使用
    • sleep()和wait()都是使线程进入阻塞状态,sleep使线程进入timed waiting状态,即一个有时限的等待过程,而调用wait()的线程则需要唤醒
    • 如果两个方法都使用在同步代码块或同步方法中,sleep()不会释放同步监视器,而wait()会释放同步监视器
    • Thread类中声明sleep()方法, Object类中声明wait()
  3. 实现Runnable接口,首先避免了继承Thread类的单继承的局限性。

    并且将线程和任务分开来

  4. 实现Runnable接口和实现Callable接口的区别

    • 一个重写run(),一个重写call()

    • call()方法有返回值,并且返回值可以带泛型,而run()方法不能有返回值

    • run()方法内部有异常的话不能抛出,只能通过try-catch进行处理,但是call()方法内部有异常,可以将异常抛出

    • 实现Callable接口要配合FutureTask使用,比如获取返回值

      // FutureTask的实例化对象的get()方法的返回值即为FutureTask构造器Callable对象的实现类所重写的call()方法的返回值

  5. join()是等待调用join方法的线程运行结束

  6. sleep() 、join()、wait(),suspend()、等待同步锁这五种情况可以让线程从运行状态到阻塞状态。

interrupt方法

  1. 线程在sleep时,被interrupt打断,会置打断标记为false

    即打断sleep的线程,会清空打断状态

    打断正常运行的线程,不会清空打断状态

    打断park线程,不会清空打断状态

    不会清空打断状态,即打断标记为真

主线程和守护线程

  1. 有一种特殊的线程叫做守护线程,只要其他非守护线程运行结束了,即时守护线程的代码没有执行完,也会强制结束。
  2. 垃圾回收器线程就是一种守护线程。

六种状态

  1. NEW ,线程刚被创建,但是还没有调用start()方法

    RUNNABLE当调用了start()方法之后,线程就是RUNNABLE状态,运行状态和就绪状态都是RUNNABLE状态

    从线程的生命周期图来看,是区分了就绪状态和运行状态。比如调用yield方法,会从运行状态到就绪状态。

  2. NEW RUNNABLE BLOCKED WAITING TIMED_WAITING TERMINATED

  3. BLOCKED WAITING TIMED_WAITING 这三种状态,是Java层面的阻塞

    比如sleep()对应就是进入TIMED_WAITING状态

    join(),就是进入WAITING状态

  4. join()是等待调用join()的线程运行结束!

    哪个线程去调用join(),那么这个线程还在运行中!别的线程必须等待调用join的这个线程运行结束才可以运行

    不要认为谁调用join(),谁就进入WAITING状态了

共享模型之管程

  1. 多个线程共享内存中的资源会造成安全隐患

    如果是多核CPU,那么多个线程并行执行,如果访问内存中的同一个资源,由于线程都是做的不同的任务,肯定会出现安全问题,即结果不确定

    如果是单核CPU,采用的是时间片轮转的方式,**那么线程间仍然是可能访问同一个资源,仍然可能出现线程安全问题。**比如两个线程交替执行,采用时间片轮转的方式,但是他们每次执行都去访问一个静态变量,这个静态变量属于类的结构,随着类的加载而加载,属于共享资源,他们会出现线程安全问题。

    总结:多个线程只要访问的是同一块资源,就可能出现线程安全问题,必须采取相应的措施。

  2. 也就是说多个线程并行执行,去访问同一个资源,会造成线程安全问题。

    单核CPU,多个线程交替执行,由于上下文切换,造成的指令交错,这多个线程又访问同一个资源,出现线程安全问题。

  3. 一个程序运行多个线程本身是没有问题的

    问题出在多个线程访问共享资源

    多个线程读取共享资源其实也没有问题

    在多个线程对共享资源进行读写操作时,发生了指令交错,就会出现问题

  4. 一段代码块内,如果存在对共享资源的多线程读写操作,称这段代码块为临界区

  5. 竞态条件

    多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称为发生了竞态条件

synchronized解决方案

  1. 为了避免临界区的竞态条件发生

    阻塞式的解决方案:synchronized,Lock

    非阻塞式的解决方案:原子变量

  2. synchronized俗称对象锁,它会采用互斥的方式让同一时刻至多只有一个线程能持有对象锁。其他线程再想获取这个对象锁时就会阻塞住。

    sleep() join() 等待同步锁 wait() suspend()

    这几种会让线程从运行态到阻塞态。

    这里说的就是等待同步锁。

    一个线程需要某个资源才能执行,这里锁就是这个资源,但是获取不到这个资源,那么这个线程会进入阻塞状态。

  3. synchronized能保证拥有锁的线程可以安全地执行临界区内的代码,不用担心线程上下文切换。

    因为只要这个线程不释放监视器,其他线程就无法执行这块代码

  4. 采用继承Thread的方式创建的线程,要注意同步监视器要使用类的对象,避免直接使用线程对象,因为要保证使用的同步监视器是唯一的。所以这种情况下不能用this

  5. 不要错误理解为拥有锁的线程就能一直执行下去,因为这个线程可能会时间片用完,但是即时这个线程的时间片用完,只要它没有释放锁,其他线程仍然进不来这块代码。只有等这个线程下一次分配到时间片,才会继续进去执行。

  6. 阻塞的线程被唤醒之后也不是说就可以立即执行了,仍然要等CPU分配时间片给这个线程。

    总之线程想要执行某一段代码,除了CPU需要分配给这个线程时间片,还要这段代码的锁(这里讨论的是同步监视器锁的情况),如果获取不到锁,就相当于获取不到资源,那么就会被阻塞。

    线程需要获取到资源才能执行某段代码,如果一直获取不到资源,那么就会被阻塞。当线程执行这段代码需要的资源被释放后,那么这个线程从阻塞态被唤醒,也不能马上执行,而是进入就绪状态,等待CPU分配时间片给这个线程。

  7. synchronized实际是用对象锁保证了临界区内代码的原子性,临界区内的代码执行不会被线程切换所打断。

    比如说某个线程拥有某段代码的锁,即时这个线程时间片用完,只要它没有释放锁,其他线程仍然不能进来执行这段代码,这段代码只有等CPU下一次分配时间片给这个线程,才可以继续执行。

    但千万不要理解为一个线程拥有某段代码的锁,就可以一直执行下去,中间仍然有时间片用完的过程,仍然存在上下文切换,但是被锁住的这段代码保证了原子性。

    **即时发生了上下文切换,这段代码也不会让其他没有拥有锁的线程来执行。**只有等这个拥有锁的线程下一次分配到时间片

  8. 再一次说明了即使是单核CPU通过时间片轮转的方式交替执行线程,仍然会出现线程安全问题,不要以为在微观上是串行的,实际没有线程同一时刻去操作同一个资源,就不会存在线程安全问题,关键在于时间片会用完,结果没保存的情况下,某个资源又被另一个线程去执行。关键在于多个线程操作共享数据,即时他们不是在同一时刻操作共享数据,而是在多个时间段操作共享数据,仍然会出现线程安全问题。因为上下文切换,发生指令交错

  9. 操作共享数据的代码,即为需要被同步的代码

    共享数据:多个线程共同操作的变量。

变量的线程安全分析

  1. 成员变量和静态变量

    • 如果他们没有被共享,则线程安全
    • 如果他们被共享
      • 如果只有读操作,则线程安全
      • 如果有读写操作,则这段代码是临界区,需要考虑线程安全问题
  2. 局部变量是线程安全的

    局部变量是在栈的栈帧里。栈是线程私有的,所以局部变量是线程安全的

    局部变量引用的对象:

    • 如果该对象没有逃离方法的作用范围,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全
  3. i++虽然在字节码指令层面,分为好几条字节码指令,不是原子操作,但是这几条字节码指令不被线程所共享。

Monitor概念

  1. Java对象在堆空间中被实例化以后,主要分为两部分

    • 对象头

      • 运行时元数据(Mark Word)----hashcode,分代年龄(从幸存者区到老年代),偏向锁,加锁状态
      • 类型指针---指向方法区中这个对象的类的信息,通过这个指针可以找到类对象
      • (如果是数组,还有数组的长度的信息)

      image-20210906143610290

      运行时元数据Mark Word格式:

      image-20210906171812809

    • 实例数据(真正的有效数据,成员变量等等)

  2. 包装类型比基本类型占用的空间大。从对象头的角度就可以看出来。

  3. Monitor是非常重要的概念,也是Synchronized锁底层的原理。

    Monitor被翻译为监视器或管程

  4. 每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针。

  5. synchronized上锁给代码块上锁,或者采用同步方法的方式,底层原理都是给对象上锁。

    image-20210906144113619

  6. 一个对象在堆空间中分为对象头和实例数据

    对象头里有运行时元数据(Mark Word)和类型指针,如果是数组还有数组的长度信息,在运行时元数据里就有hashcode,分代年龄,偏向锁和加锁状态的信息。

    给一个对象上锁之后,这个Java对象的对象头的运行时元数据(Mark Word)就指向(关联)一个Monitor对象。运行时元数据里存储了一个指向Monitor的地址指针

  7. Monitor里的属性

    • Owner,是锁的所有者

      Monitor的这个Owner只能有一个所有者。

    • EntryList,是等待队列

      比如当前Monitor的Owner,即拥有当前这个Monitor的线程是Thread1,那么当Thread2来判断是否可以访问这段临界代码时,首先判断这个锁对象obj的运行时元数据(Mark Word)有没有指向Monitor的指针,即有没有关联一个Monitor对象,如果有,再判断这个Monitor的Owner有没有所有者,如果有的话,就进入EntryList等待队列(或者叫阻塞状态),那么这个Thread2本身进入阻塞状态(BLOCKED),等待其他线程释放对Monitor的所有权。

      如果此时又有一个线程来了,也要来执行这段临界代码,首先发现obj关联了Monitor对象,然后Owner有主人,也进入EntryList阻塞队列,那么该线程,从运行状态也进入阻塞状态(BLOCKED)

      当一个线程需要某样资源继续执行的时候,但是又获取不到这样资源,那么会进入阻塞状态。

      上面说的线程进入阻塞状态的情况,是等待同步锁,然后进入阻塞状态。

      一个线程从运行状态进入阻塞状态,有以下五种情况

      • join()---由RUNNABLE到WAITING
      • sleep()----由RUNNABLE 到 TIMED_WAITING
      • wait()---由RUNNABLE到WAITING
      • suspend() (已经deprecated)
      • 等待同步锁---由RUNNABLE到BLOCKED

      BLOCKED, WAITING, TIMED_WATING三种状态在Java的线程状态这个角度来说,都叫阻塞状态

      image-20210906145616412

  8. 对synchronized对代码块或方法上锁的原理描述

    • 刚开始Monitor中Owner为null
    • 当Thread-2执行synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只能有一个Owner
    • 在Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5也来执行synchronized(obj),就会进入EntryList 由运行态到BLOCKED
    • Thread-2执行完同步代码块的内容,即临界区的代码,然后唤醒EntryList中等待的线程来竞争锁,竞争的时候是非公平的。
  9. JVM基于进入和退出Monitor对象来实现同步方法和同步代码块

    但两者的实现细节不一样,代码块同步是使用monitorenter和monitorexit字节码指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是,方法的同步同样可以使用这两个指令来实现。

    monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处

    JVM要保证每个monitorenter必须有对应的monitorexit与之配对。

    任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态,线程执行到monitorenter字节码指令时,将会尝试获取上锁对象所对应的monitor的所有权,即尝试获得对象的锁

  10. synchronized必须是进入同一个对象的monitor才有效果,所以一定要保证同步监视器只有一个。

synchronized进阶原理

  1. synchronized工作方式是让对象关联monitor对象,但是monitor这个锁是由操作系统提供的,使用Monitor成本比较高,如果每次进入synchronized(xxx)都要获取Monitor锁,对程序运行的性能是有影响的。

  2. 从Java6对synchronized关键字获取锁的方式进行了改进和优化。

    从直接使用Monitor锁改成了可以使用轻量级锁和偏向锁

  3. Monitor是属于重量级锁

  4. 没有竞争的时候,就是说如果使用共享资源的时间是错开的时候,可以使用轻量级锁,如果使用共享数据的时间没有错开,那么轻量级锁也会升级为重量级锁。

  5. jdk1.6版本,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,在jdk1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。这几个状态会随着竞争情况逐渐升级,锁可以升级但是不能降级,意味着偏向锁升级为轻量级锁之后不能降级成偏向锁。

轻量级锁

  1. 轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。

    如果有竞争,轻量级锁会升级为重量级锁

    轻量级锁的语法仍然是synchronized

  2. 轻量级锁工作原理

    1. 在线程的虚拟机栈的栈帧创建锁记录(Lock Record)对象

      每个线程的栈帧都会包含一个锁记录的结构。

      内部可以存储锁定对象的Mark Word(运行时元数据)

      image-20210906162329611

      这个Lock Record包含了两部分

      • 一部分是对象指针,锁住哪个对象,得知道对象地址,这一部分就是指向加锁对象的对象引用
      • 另一部分是锁记录地址和后两位标识00(00代表轻量级锁),这一部分将来用来存储加锁对象的Mark Word
    2. 让锁记录中Object reference指向锁对象,并尝试用cas替换Object的Mark Word,将Mark Word的值存入锁记录(关键)。

      做这一步交换的操作就表示加锁

      image-20210906162952714

    3. 如果CAS替换成功,对象头中存储了锁记录地址和状态00表示这个对象已经加了轻量级锁

      锁记录里面存放了对象的对象头的Mark Word(运行时元数据--hashcode,分代年龄等)

      将来解锁的时候,再恢复回去

      image-20210906163537132

      上面说的是成功的情况,如果对象的对象头的Mark Word标记位是01,代表是正常情况,没有加锁,那么这种情况,交换可以成功,即加锁可以成功

    4. 如果CAS替换失败,有两种情况

      • 如果是其他线程已经持有了该Object对象的轻量级锁(Object对象对象头里标记是00),这时表明有竞争,进入锁膨胀过程

      • 如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数。

        但是这种失败没关系,因为是同一个线程又对同一个对象加锁,也会在新的栈帧(虚拟机栈的存储单位)里创建锁记录,锁记录地址部分存为null,这种情况叫做synchronized锁重入

        image-20210906164102507

      CAS操作是原子性的,不会被打断

      总结:同一个线程对同一个对象再加锁,这种情况叫synchronized锁重入

    5. 解锁:

      • 如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

        image-20210906164652141

      • 当退出synchronized代码块时,锁记录的值不为null,这时使用cas将 Mark Word的值恢复给对象头

        • 成功,则解锁成功
        • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

锁膨胀

  1. 如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

  2. 锁膨胀过程

    • 当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁

      image-20210906165418735

    • 这时Thread-1加轻量级锁失败,进入锁膨胀流程

      • 即为Object对象申请Monitor锁,让Object对象指向重量级锁地址。并且Mark Word后两位会变为10,表示重量级锁。
      • 然后自己进入Monitor的EntryList 进入阻塞状态BLOCKED

      image-20210906165650753

    • 当Thread-0退出同步块解锁时,使用cas将Lock Record中Mark Word的值恢复给对象头,失败

      进入重量级锁解锁流程。即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒EntryList中BLOCKED线程

自旋优化

  1. 线程阻塞,那么就要发生一次上下文切换,这是比较耗费性能的。

    重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

    意思就是不立刻去EntryList,进入阻塞状态,而是循环几次,就是自旋

  2. 自旋要使用CPU,所以自旋优化适合多核CPU,这样才有意义,否则单核CPU,正在被持锁线程所占有,那么这个线程自旋,就没有意义。

  3. 如果持锁线程1迟迟没有释放锁,那么线程2自旋重试失败,仍然进入EntryList,进入阻塞态。

  4. 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。

偏向锁

  1. image-20210906171021229

  2. image-20210906172322150

  3. 偏向锁的语法仍然是synchronized

    默认开启偏向锁,初始线程ID是0,上锁之后(加了synchronized),才有线程ID

    释放了锁之后,对象的对象头的Mark Word里的线程ID仍然是之前的线程ID,这就是偏向!

  4. 加锁的优先顺序是

    • 偏向锁
    • 轻量级锁
    • 重量级锁(线程2想进入临界代码,发现锁对象已经被线程1加轻量级锁,这时就通过锁膨胀,给Object对象加重量级锁)
  5. 偏向锁的撤销

    • 当一个默认打开偏向锁的对象,调用了hashcode()之后,会撤销这个对象的偏向锁状态,因为如果开了偏向锁,要存线程ID那些数据,对象头里便存不下hashcode,所以就把偏向锁状态关闭。

      轻量级锁的对象的hashcode会存在线程的栈的栈帧里的lock record的锁记录地址部分,解锁的时候会恢复给对象。

      重量级锁的对象的hashcode会存在monitor对象里,所以不会影响。

    • 偏向锁本质就是只有一个线程在使用,线程多了之后,产生竞争,锁自然会升级。

      当有其他线程使用偏向锁对象(synchronized括号内的对象)时,会将偏向锁升级为轻量级锁。

      解锁之后,这个对象就是不开启偏向锁的状态

      这也是偏向锁的撤销。

  6. 偏向锁在jdk6和7里是默认启用的,但是它在应用程序启用几秒钟后才能生效,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay = 0,如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁,那么程序默认会进入轻量级锁状态。

  7. 批量重偏向

    过程:

    假如锁对象被两个线程访问,但是没有竞争,这时偏向了线程T1的对象仍然有机会重新偏向T2

    重偏向会重置对象的对象头里的Mark Word的线程ID

    • 线程T1,给一个对象重复上锁,线程T2调用wait(),进入阻塞态,所以此时只有T1来给对象上锁,并且在一个循环内多次上锁,那么上的是偏向锁,因为没有竞争(循环30次)
    • 循环完之后,唤醒T2线程,T2线程访问对象,此时发现对象已经被上了偏向锁,那么就如上面说的,会进行偏向锁的撤销,首先会将偏向锁升级为轻量级锁,解锁之后,这个对象就是不开启偏向锁的状态即撤销了偏向锁
    • 上面这一步要进行19次(前19次的对象都已经变成了不可偏向的状态),到第20次的时候,JVM会觉得之前偏向T1偏向错了,于是在给这些对象加锁时,重新偏向T2线程。
  8. 批量撤销

    当撤销偏向锁阈值超过40次后,JVM会觉得偏向错了,不应该采取偏向锁,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

  9. 批量重偏向和批量撤销,都是属于优化。

锁消除

  1. 锁消除开关是默认打开的,JVM会自动消除掉没有意义的锁
  2. 因为上锁和解锁都是有成本的,而且上锁会导致有的线程获取不到锁进而会到阻塞状态,这个过程会引起线程的上下文切换,也要消耗成本

wait-notify

  1. 被唤醒后的线程,是进入就绪状态,与其他线程一起等待CPU分配时间片,再执行,而不是被唤醒后马上执行

    被唤醒后的线程重新进入竞争锁的队列

  2. wait()、notify()、notifyAll()

    • 这三个方法只能出现在同步代码块和同步方法中

    • 以上这三个方法的调用者必须是同步代码块或同步方法中的同步监视器。

    • 以上这三个方法是定义在java.lang.Object中的。因为同步监视器任何一个对象都可以充当,结合第二点,任何一个对象都必须能够调用以上三个方法,说明,任何一个对象都有这三个方法,所以这三个方法是声明在Object类中。

  3. 原理

    image-20210906200431146

    • Owner线程发现条件不满足,调用wait()方法,即可进入WaitSet变为WAITING状态

    • BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片

    • BLOCKED线程会在Owner线程释放锁时唤醒

    • WAITING线程会在Owner线程(同步监视器锁对象)调用notify或notifyAll时唤醒。

      但是唤醒后并不意味着立刻获得锁,仍需进入EntryList(BLOCKED的对象在这里)重新竞争。

    • wait()、join()是进入waiting状态

    • yield()是从running到runnable就绪状态,让出时间片但是不阻塞

    • 时间片用完也是从running到runnable就绪状态,不阻塞,回到就绪状态等待下一次时间片分配

    • sleep是到timed_waiting状态

    • 等待同步锁是到BLOCKED状态。

      要区分WAITING状态和BLOCKED状态。

    • WAITING状态的线程通过notify()或notifyAll()唤醒,唤醒后的线程仍然要进入EntryList队列,等待CPU资源分配
    • BLOCKED状态通过线程释放锁唤醒
  4. 锁和时间片都可以理解为CPU的资源

  5. obj是同步监视器锁对象

    • obj.wait()让进入object监视器(monitor)的线程到waitSet等待
    • obj.notify()在object上正在waitSet等待的线程中挑一个唤醒
    • obj.notifyAll()在object上正在waitSet等待的线程全部唤醒

    以上三个方法都属于Object对象的方法

    要注意只有当某个线程获取到锁之后,才能调用以上这三个方法

    注意,调用wait(),是使本线程进入到WAITING状态(阻塞状态)

    而调用notify和notifyAll是唤醒别的线程

  6. image-20210906204102015

同步模式之保护性暂停

  1. join()的实现采用的就是此模式,一个线程等另一个线程执行完之后再执行。
  2. join()使线程进入WAITING状态
  3. 这种模式是在两个线程间交互结果的模式
  4. image-20210906211624798

join原理

  1. 保护性暂停是一个线程等待另一个线程的结果

  2. join()是一个线程等待另一个线程的结束

  3. 设置了最大等待时间

    image-20210906221104293

异步模式之生产者消费者模式

  1. 与保护性暂停模式不同,不需要产生结果和消费结果的线程一一对应。
  2. 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
  3. 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
  4. 和保护性暂停的共同点都是处理多个线程间的结果的交互

park和unpark

  1. image-20210907152327885

  2. 原理:

    先调用park,再调用unpark

    image-20210907153022187

    image-20210907153037877

    先调用unpark,再调用park

    image-20210907153118988

线程状态转换

  1. 情况1 NEW---> RUNNABLE

    当调用t.start() 方法时,由NEW ---> RUNNABLE

  2. 情况2 RUNNABLE ---> WAITING

    t线程用synchronized(obj)获取了对象锁后

    • 调用obj.wait() 方法时,t线程从RUNNABLE ---> WAITING
    • 调用obj.notify() , obj.notifyAll(), t.interrupt() 时
      • 竞争锁成功,t线程从WAITING --> RUNNABLE
      • 竞争锁失败, t线程从WAITING ---> BLOCKED
  3. 情况3 RUNNABLE ---> WAITING

    • 调用t线程.join()方法,要注意一定不是t线程从RUNNABLE ---> WAITING

      是当前线程进入WAITING而不是t线程,是当前线程等待t线程运行完!

      注意当前线程是在t线程对象的监视器上等待。

    • t线程运行结束,或调用了当前线程的interrupt(),会让当前线程从WAITING -- RUNNABLE

  4. 情况4 RUNNABLE ---> WAITING

    • 当前线程调用LockSuppor.park()方法会让当前线程从RUNNABLE -- WAITING (从原理上分析过了,也不一定,要看counter的状态。)
    • 调用LockSupport.unpark(目标线程)或调用了线程的interrupt(),会让目标线程从WAITING -- RUNNABLE
  5. 情况5 RUNNABLE -- > TIMED_WAITING

    • Thread.sleep(long time)

    • obj.wait(long time)

    • 当前线程调用t.join(long n)方法时,当前线程从RUNNABLE---TIMED_WAITING,当前线程等待t线程运行。

      当前线程等待时间超过了n毫秒,或t线程运行结束,或调用了当前线程的interrupt时,当前线程从TIMED_WAITING --- > RUNNABLE

  6. 情况6 RUNNBALE -- > BLOCKED

    • t线程用synchronized(obj)获取对象锁时,如果竞争失败,从RUNNABLE -- > BLOCKED

ReentrantLock

  1. 使用jconsole可以检测死锁

  2. 饥饿,一个线程由于优先级太低,始终得不到CPU调度执行,也不能够结束

  3. 用ReentrantLock可以解决死锁和饥饿现象。

  4. ReentrantLock是可重入锁,是JUC并发工具包下的一个重要的类

  5. 相对于synchronized,它具备如下特点:

    • 可中断(synchronized加上锁之后,是不可以中断的。),加入打断机制可以防止线程等待锁时无限制地等待下去。为了避免死等。

      lock.lockInterruptibly()

    • 可以设置超时时间(规定时间内,如果获取不到锁,就放弃争这个锁了,去执行一些其他的逻辑),也是防止线程因为等待锁进入阻塞状态时,防止线程无限制地等待下去。一定时间之后,就不再阻塞了,不再无限制地等待下去。

      lock.tryLock()

      这种方式同样支持打断。括号里可以填入时间参数,表示等待多长时间,如果这段时间没有获取到锁,那么就不会再继续等待。

      用tryLock()可以解决哲学家就餐问题

    • 可以设置为公平锁(防止线程饥饿的情况)

      ReentrantLock默认是不公平的

    • 支持多个条件变量。(不像synchronized只有一个waitSet,比如要用notifyAll叫醒线程,会把等待的线程都叫醒,而ReentrantLock可以对waitSet进行细分,相当于这个意思)

    • 与synchronized一样,都支持可重入。

  6. 可重入---同一个线程对同一个锁的重复获取

    是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获得这把锁。

    如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住。

  7. Synchronized的monitor锁是不公平锁

    谁先抢到谁就拥有锁,而不会按照阻塞队列的顺序先来先得。所以是不公平的。

    **ReentrantLock默认是不公平的,**但是可以通过构造方法设置是公平还是不公平。

    公平锁一般没有必要,会降低并发度

    公平锁是按照等待队列,先入先得的方式实现的。

  8. 公平锁本意是解决饥饿问题的,但是实际上没有必要,用tryLock可以解决。

    一般都不会设置为公平锁。

  9. ReentrantLock默认是不可打断的。

  10. image-20210907164801520

和之前的wait-notify-notifyAll相比,是根据条件变量来叫醒。

调用await()之前,必须获得锁才有资格。(和wait,notify,notifyAll一样)

注意调用await()和signal()方法的对象是Condition对象。

  1. ReentrantLock的lock()、unlock()必须成对出现,手动解锁,而synchronized关键字不需要

本章小结

  1. 同步解决的问题和互斥解决的问题不一样

    互斥主要保证共享资源的互斥效果,一段临界区代码在在某一段时间内只能由一个线程去执行

    而同步指的是某个线程拿到其他线程执行代码的结果之后才可以被唤醒继续进入阻塞队列去竞争锁。也就是某个线程需要资源,但是得不到资源,于是进入阻塞队列,但是获得资源后,便被唤醒进入就绪状态,一定要注意,这里被唤醒是进入就绪状态而不是直接执行,仍然要等待CPU分配时间片。

  2. 线程获取不到锁,是进入EntryList阻塞队列

    而线程某样条件不满足,是进入WaitSet休息室,可以这么理解。

    平时都把锁和线程需要获得的其他资源都看作线程需要的资源,但是从线程差哪样东西而进入哪种状态来看,需要把锁和线程需要的某些条件分开

    线程需要锁但是没有获得,于是进入BLOCKED状态

    线程需要资源但是没有获得,进入WAITING状态。

  3. Synchronized和Lock默认都是非公平的,不过ReentrantLock可以设置公平锁。

  4. Synchronized

    • monitor,JVM层面的重量级锁
    • 轻量级锁
    • 偏向锁
  5. 互斥是指共享资源的互斥效果

    同步是指使用条件变量waitSet来达到线程间的通信效果。

并发之共享模型

  1. JMM---Java内存模型

    • 原子性--保证指令不会收到线程上下文的切换
    • 可见性--- 保证指令不会受CPU缓存的影响
    • 有序性---保证指令不会受CPU指令并行优化的影响
  2. JMM定义了

    • 主存---成员变量等,共享数据
    • 工作内存----线程的私有数据,比如局部变量等
  3. 加了volatile的意思就是说就不能从缓存即线程的工作内存中读取了,每次都必须从主存中读取变量的最新值。

  4. volatile可以用来修饰成员变量和静态成员变量

    不能修饰局部变量,因为局部变量是线程私有的,不允许共享。

    它可以避免线程从自己的工作缓存中查找变量的值,必须到主存获取它的值

    线程操作volatile变量都是直接操作主存

  5. 可见性就是说当热点代码的变量被缓存到线程的工作内存中,而要对某个变量进行修改,是在主存中进行修改。

    那么线程能不能获取到这个主存中的修改的变量,还是继续在自己的工作缓存中找这个变量。

    就是这个修改的变量对于线程是否可见。

  6. synchronized也能保证变量的可见性,但是要创建monitor锁,也就是同步监视器锁,是比较重量级的操作

    但是volatile是比较轻量级的

    如果只是要保证可见性,那么推荐volatile

  7. 一个变量加了volatile修饰,保证的是一个线程对volatile变量的修改,对另一个线程可见,不能保证原子性。仅用在一个线程是修改变量,另外的线程只是读取变量

  8. volatile只是保证线程能看到变量的最新值,并不能解决指令交错,即并不能保证某段代码的原子性。

    synchronized既能保证代码块的原子性也能保证代码块内变量可见性。缺点就是属于重量级操作,性能更低

  9. i++,i--这种操作,在字节码指令层面,他们的指令不是原子性的。可能产生指令交错。

  10. 两个线程共享某个变量,volatile就是保证这两个线程对这个变量的可见性,即修改了之后线程也能获得这个变量的最新值。而不是获得之前线程的工作内存(缓存)的变量的值。

指令重排序优化

  1. 在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级层面的并行。
  2. CPU在某一刻执行多条指令的不同阶段,不改变程序结果。
  3. 如果下一条指令依赖上一条指令的结果,那么这种情况就不能重排序。

volatile原理

  1. volatile的底层实现原理是内存屏障

  2. 对volatile变量的写指令后会加入写屏障

    对volatile变量的读指令前会加入读屏障

  3. 写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存当中

    读屏障保证在该屏障之后的,对共享变量的读取,加载的是主存中最新数据

  4. 写屏障之前的代码可以保证不会发生指令重排,就是写屏障之前的代码不会出现在写屏障后。

    读屏障会保证读屏障之后的指令不会因为指令重排序出现在读屏障前。

    写屏障和读屏障都是对volatile修饰的变量进行修改和读取时附带的屏障。

  5. volatile保证了共享变量的可见性和有序性,但是不能解决指令交错

    有序性的保证只是保证了本线程内相关代码不被重排序,通过写屏障和读屏障。

  6. 饿汉式单例模式,类加载就会导致该单实例对象被创建

    懒汉式,类加载不会导致该单例对象被创建,而是首次使用该对象时才会创建。

共享模型之无锁(非阻塞)

  1. 管程也叫monitor或监视器锁

    是悲观锁

  2. CAS与volatile结合就可以实现无锁并发。

  3. CAS与volatile结合的工作方式

    image-20210908142615745

    compareAndSet比较并设置值,在CPU的指令级别或者说字节码指令级别可以实现其原子性。

  4. image-20210908143216090

    CAS可能会失败,就是如果cas的第一个参数的值和调用cas方法的对象的属性的最新值不相同(不相同是因为,在获取到“最新值”之后,执行之后的逻辑时,有其他的线程进来操作这个共享变量,导致之前获取的最新值已经不是最新了),就会失败,失败之后会继续下一次循环,继续获得最新值,再执行之后的逻辑。

  5. cas需要volatile的支持,cas必须借助volatile才能读取到共享变量的最新值来实现比较并交换的效果。

    volatile可以保证共享变量的可见性和相关代码的有序性,但是它不能解决指令交错的问题,它不能保证代码块的原子性。而CAS的操作在字节码指令层面是原子的,不可被打断。所以CAS和volatile结合起来可以实现无锁并发。

    synchronized关键字可以保证代码块的原子性和代码块内部共享变量的可见性。

    • 原子性 -- synchronized
    • 可见性--synchronized,volatile
    • 有序性---volatile
  6. cas是比较和设置,涉及到比较,指的就是即使失败,下一次进去循环,仍然能够获取到最新值,用当前最新值来进行比较,即使这个过程中,最新值被另一个线程所修改而不是最新值,在下一次循环仍然能够拿到最新值比较。那么要保证每次循环都能拿到这个最新值,就要volatile的配合。

  7. 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇

    而synchronized会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。

    image-20210908144636043

    无锁状态也可能会有上下文切换,只不过这里不是进入阻塞状态,而是因为没有分到时间片从运行态进入就绪状态(可运行状态),还是会导致上下文切换。

  8. 在线程数小于CPU核心数的时候,用CAS是非常合适的。

  9. CAS是基于乐观锁的思想,不怕别的线程来修改共享变量,而synchronized是拿到锁,禁止其他线程来修改共享变量,而CAS+volatile是不怕别的线程修改共享变量,大不了自己再重试几次,结合volatile每次都可以从主存拿到最新修改的值,来重试。

    乐观锁虽然有一个“锁”字,但是其实没有上锁

  10. synchronized是基于悲观锁的思想,禁止其他线程来修改共享变量,拿到锁的线程解开锁,别的线程才可以进来。

  11. CAS保证了原子性,volatile保证了共享变量的可见性,那么通过不断重试总能拿到正确的最新值,不怕别的线程对共享变量进行修改,所以说这是乐观锁。

  12. CAS体现的是无锁并发、无阻塞并发。(当线程数大于CPU核心数的时候,还是可能出现线程的上下文切换,但是不会有阻塞,这里的上下文切换是线程由运行状态到可运行状态引起的。)

    • 没有使用synchronized,线程不会陷入阻塞,这是效率提升的因素之一

    • 但是如果竞争激烈,重试必然频繁发生,反而效率会受影响。

  13. value.compareAndSet(int expect, int update)

    会把第一个参数期望值和value的最新值做对比,如果一致,那么可以用后面的update,更新value,如果更新失败则返回false

  14. ++i,i++这种操作在字节码层面不是原子性的,所以可能会出现线程安全问题,在字节码层面,会出现指令交错,比如多个线程对i这个共享变量,进行这类操作。

  15. 原子整型AtomicInteger里会维护一个value值,这个value值,就是会用CAS来保证它的线程安全,CAS保证指令的原子性,但是这个value值是用了volatile来修饰保证了它的可见性,就是它每一次修改之后,都保证能在主存拿到最新值来进行比较和设置。

    image-20210908152216836

  16. CAS应用在线程数比较少的时候,最好是不要超过CPU核心数。

  17. AtomixStampedReference可以给原子引用加上版本号,追踪原子引用整个的变化过程,可以知道引用变量中途被更改了几次

    如果只是单纯关心原子引用变量是否更改过,就用AtomicMarkableReference

原子累加器

  1. 原子累加器 LongAdder性能提升的原因:

    在有竞争时,设置多个累加单元,最后将结果汇总,这样他们在累加时操作的不同的cell变量,因此减少了CAS重试失败。从而提高了性能。

final原理

  1. final变量的赋值也会通过putfield指令来完成,同样在这条指令后也会加入写屏障

    写屏障就是之前的指令不会在写屏障之后执行,写屏障之前对变量的修改会同步到主存中

    加入了写屏障,保证在其他线程读到它的值时不会出现为0的情况

  2. 如果一个成员变量不加final修饰,那么相当于是给一个变量赋值。这个变量先要默认赋初始值0,然后才会显式赋值。其他线程有可能会看到这个0从而导致线程安全问题。

    在类加载过程中的链接阶段的准备阶段,会给成员变量中的静态属性默认赋初始值,然后在初始化阶段给其显式赋值。这个准备阶段,不会给成员变量中的非静态属性赋值,因为非静态属性是属于对象的变量,需要随着对象的创建而被分配在堆空间中,也不会给final修饰的静态属性赋值,因为final修饰的静态属性早在编译阶段就会进行赋值了。

    类加载过程中的初始化阶段,<clinit>,给静态属性显式赋初始值,执行静态代码块内的内容。

自定义线程池

  1. 我们应该充分利用已有线程的潜力,不应该每次一有任务都新建新的线程,不然线程很多,会频繁地出现上下文切换,甚至是死锁,还有系统资源限制的问题,都说明了线程不是越多越好,所以要充分利用已有线程。

  2. 每创建一个线程都要占用一定内存,要分配虚拟机栈等线程私有空间

    如果创建很多线程

    • 对内存的占用大,也就是系统资源限制问题
    • 上下文切换导致效率不高
    • 死锁
  3. 线程一定不是创建得越多越好。

  4. 线程池就是创建一些线程,让线程能够得到重复的利用,线程用完了,不销毁,放进线程池。

  5. 线程池

    • jdk提供的功能完备的线程池
    • 自定义线程池
  6. 自定义线程池分成以下组件

    • 线程池,里面有可重用的线程
    • 阻塞队列,平衡生产者线程、消费者线程速度差异的组件
    • 线程的生产者

    image-20210908183204652

jdk定义的线程池

线程池状态

  1. image-20210908205551426

    ExecutorService是线程池的最基本的接口,提供了提交任务,关闭线程池等方法。

    ScheduledExecutorService新增了调度任务的功能,用于定时执行任务

    ThreadPoolExecutor是最基础的实现

    ScheduledThreadPoolExecutor是带有任务调度功能的线程池实现

  2. 让有限的工作线程(Worker Thread)来轮流地异步地处理无限多的任务,线程池做的就是这个事情。

  3. 线程池状态

    ThreadPoolExecutor使用int的高3位来表示线程池状态,低29位表示线程数量

    image-20210908205814692

    线程池状态和线程数量存储在一个原子变量ctl中,目的是将线程池状态与线程个数合二为一,这样就可以用一次cas原子操作进行赋值。

构造方法

  1. 线程池的构造方法

    image-20210908210307737

    阻塞队列指的是任务阻塞队列。要清楚任务是什么,看泛型很清楚,里面写的Runnable,Runnable或Callable接口的实现类的对象就是任务对象

核心线程和救急线程

  1. 线程池中的线程分为两种

    • 核心线程
    • 救急线程

    核心线程数 + 救急线程数 = 最大线程数

  2. 救急线程用于任务量特别大,任务如果没有核心线程来执行,那么会进入阻塞队列,当阻塞队列都放不下了,来的下一个任务会交给救急线程去执行。

    image-20210908211936243

  3. 救急线程和核心线程最大的区别是:

    救急线程有生存时间,任务执行完了,救急线程会销毁,下一次高峰期来了,才会再创建

    核心线程没有生存时间,执行完任务,仍然会保留在线程池中。

    • 线程池中刚开始没有线程,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务
    • 当线程数达到corePoolSize后,并没有线程空闲,这时再加入任务,新加的任务会被加入workQueue队列排队,直到有空闲的线程。
  4. 如果线程数达到maximumPoolSize,仍然有新任务这时会执行拒绝策略,拒绝策略jdk提供了4种实现:

    image-20210908212728493

    image-20210908212946761

  5. jdk提供了Executors工具类,这个工具类提供了很多工厂方法来创建各种用途的线程池,但是内部就是调用构造方法,传递不同参数,创建线程池

  6. 总结:

    线程数达到corePoolSize:

    • 新加的任务加入任务阻塞队列排队,直到有空闲的线程
    • 若任务阻塞队列已满(即任务阻塞队列已经放不下新加的任务):
      • 救急线程来执行新加的任务
      • 若核心线程数 + 救急线程数 即最大线程数已经达到maximumPoolSize,如果仍然有新任务,执行拒绝策略(4种)

几种类型的线程池

  1. 固定大小线程池

    newFixedThreadPool

    image-20210908213524594

    特点:

    • 核心线程数 == 最大线程数(没有救急线程被创建),因此也无需超时时间

    • 阻塞队列是无界的,可以放任意数量的任务

      适用于任务量已知,相对耗时的任务

  2. 带缓冲线程池

    newCachedThreadPool

    image-20210908214251452

    特点:

    • 核心线程数是0,最大线程数是Integer.MAX_VALUE,救急线程的空闲生存时间是60s
    • 全部都是救急线程(60s后可以回收)
    • 救急线程可以无限创建

    适用于任务密集,但是每个任务时间都很短的情况

  3. 单线程线程池

    image-20210909140256403

    使用场景:

    希望多个任务排队执行,线程数固定为1,任务数多于1时,会放入无界队列排队

    任务执行完毕,这唯一的线程也不会释放。

    只有一个核心线程,没有救急线程

    区别:

    自己创建一个单线程串行执行任务,如过任务执行失败而终止没有任何补救措施,而线程池还会新建一个线程,保证线程池的正常工作

    image-20210909141045584

    单线程执行器返回的是包装后的对象,核心线程数大小不能修改。

提交任务

  1. 提交任务

    image-20210909141325138

    • submit

      Future用的就是保护性暂停模式来接收另一个线程返回的结果。

    • invokeAll

      image-20210909142412154

      image-20210909142301150

      意思就是说,invokeAll()方法传入的参数是任务tasks,任务的本质也是实现Callable接口的类的对象

      而创建线程池时指定的最大线程数即核心线程数(因为采用的是固定大小线程池,所以没有救急线程)是2,所以前两个任务,也就是实现Callable接口的类的对象,(我认为任务的本质就是实现Runnable或Callable接口的类的对象,把这个对象,作为哪个线程创建的构造函数的参数传入,就是任务交给哪个线程执行。

      前两个任务被线程执行了,第三个任务进入阻塞队列,前两个任务执行完后,第二个线程将第三个任务取出执行。

      任务就是实现Runnable接口或Callable接口的实现类的对象

      怎么把任务交给某个线程执行呢?我们之前说把任务和线程分开,就是采用的实现Runnable或Callable接口的方式,那么这里同样,把任务交给某个线程执行,就相当于把实现Runnable或Callable接口的对象,作为创建线程的构造函数的参数传入

    • invokeAny, 找到一个最先执行的任务,返回结果之后,其他任务就不执行了。

      返回结果类型是Object,也就是说最终只得到一个任务的结果。

  2. 任务对象:实现Runnable接口或实现Callable接口的对象叫做任务对象,交给线程执行,即作为线程创建的构造函数的参数传入

关闭线程池

  1. shutdown---使线程池状态从RUNNING变为SHUTDOWN

    不会接收新任务

    但已提交的任务会执行完,会把阻塞队列中等待任务对象执行完毕

    此方法不会阻塞调用线程的执行,比如主线程调用了shutdown,主线程如果还有其他代码,会继续运行。

  2. shutdownNow---使线程池状态从RUNNING变为STOP

    不会接收新任务

    会抛弃阻塞队列中的任务(其实不准确,阻塞队列中没执行的任务会作为结果返回)

    会用interrupt的方式中断正在执行的任务

    队列中的任务会作为返回结果,拿到这个返回结果是重新执行,还是抛弃,由业务来决定。

    image-20210909152652554

    这里又证明了,Runnable,Callable接口实现类的对象就是任务对象

任务调度线程池

  1. 有的时候我们希望任务延时执行,或者希望任务每隔几秒就执行一次(定时),这种情况我们就要用到任务调度线程池

    任务调度线程池是在jdk1.5加入。

  2. 在任务调度线程池加入之前,有一种java.util.Timer来实现定时功能,Timer的优点是简单易用,但由于所有的延时或定时的任务都是由同一个线程来调度执行,所以所有的任务都是串行

  3. Timer已经过时了

    Timer的这个线程很脆弱,前一个任务出现了异常,后面的任务就不能执行了。

    应该用带有任务调度功能的线程池,可以设置线程池中线程数量,就不是所有的任务都是一个线程来执行了。(多线程解决串行,实现并行(CPU多核的情况下,单核的情况下微观上仍是串行,上下文切换))

  4. image-20210909163938947

    Executors是工具类,创建各种线程池,本质上是修改构造方法的那七种参数

  5. 正确处理异常

    1. 第一种方式,自己手动在Callable实现类复写call()方法时,或在实现Runnable类复写run()时,用try-catch处理
    2. 第二种,用实现Callable接口结合Future的方式替代实现Runnable接口的方式,因为实现Callable接口结合Future的方式,重写call()方法,call()方法有返回值,这个返回值,通过Future对象的get()方法拿到,如果说call()内部有异常,那么在通过Future对象的get()方法拿返回结果时,会将异常信息打印出来。

Fork/Join线程池实现

  1. Fork/Join线程池是jdk1.7加入的新的线程池实现

    体现的分治思想

    适用于能够进行任务拆分的cpu密集型运算

  2. 所谓任务拆分,是将一个大任务拆分为算法上相同的小任务

    这些小任务就可以交给不同的线程来完成,提升运行效率

  3. Fork\Join默认会创建与CPU核心数大小相同的线程池。(要充分利用CPU)

  4. 使用Fork\Join线程池

    • 创建任务对象,不能使用Runnable或Callable任务对象
    • 使用Fork\Join线程池执行任务对象
  5. image-20210909175235649

    如果是无参的,那么创建的线程数就等于CPU核心数

    哪种线程池用什么方法来执行任务要搞清楚。

    比如任务调度线程池用的schedule

    Fork\Join线程池用的invoke

  6. Future是一个接口,FutureTask是实现了RunnableFuture接口的实现类,RunnableFuture接口继承于Future接口和Runnable接口

异步模式之工作线程

定义

  1. 让有限的资源即工作线程处理无限的任务,典型实现就是线程池。而且使用线程池可以做到将线程重复使用,也不用每次新建线程或销毁线程,提高了效率,节省了时间成本。

    线程池可以设置最大线程数和核心线程数,可以通过有限的线程处理无限的任务,一个线程执行一个任务对象完成之后,并不销毁,而是放回到线程池中,进行下一个任务对象的执行。

    任务对象除了一进来就能够被线程执行以外,没有能被执行的线程都放进线程阻塞队列中。

    任务对象出队列,被线程执行,是这种流程。

    自定义线程池的时候如果已有线程数小于最大线程数,那么就创建工作线程

  2. 对应于一个业务,也可以创建多个线程池,不是只能有一个线程池。

饥饿现象

  1. 用固定大小的线程池,会有饥饿现象,线程数不足,导致饥饿

    用带缓冲的线程池,不会有饥饿现象

    单线程线程池,会有饥饿现象。

  2. 对于不同的工作任务,应该使用不同的线程池,来避免饥饿现象。

    避免因为多个线程都去执行任务A,而没有线程去执行任务B,如果按任务来划分了线程池,那么就不存在没有线程去执行任务B的情况。

    这种方式比单纯增加线程池的容量更为重要

  3. 线程池中的线程数量过小,可能会导致饥饿

    过大的话会导致更多的线程产生上下文切换(上下文切换对于CPU来说是一种开销),并且涉及到系统资源不足的问题,线程过多也可能会发生OOM

    • CPU密集型运算

      通常采用cpu核数 + 1,能够实现最优的CPU利用率。

      +1 保证当线程由于页缺失故障或其他原因导致暂停时,额外的这个线程能顶上去,保证CPU时钟周期不被浪费,保证CPU的利用率。

    • IO密集型运算

JUC

AQS原理

  1. 其他的并发工具都是依赖于AQS的,抽象的基于队列的同步器。是阻塞式锁和相关的同步器工具的框架

    Synchronized锁就是阻塞式的锁,CAS+volatile就是实现的无锁并发,是乐观锁,线程不阻塞。

    但是AQS并不是Synchronized的原理,而是ReentrantLock的实现原理,ReentrantLock是阻塞式锁

    Synchronized的实现原理是Monitor,是JVM层面的,底层是用C++来实现的。

  2. AQS ---- AbstractQueuedSynchronizer,是一个抽象类,抽象的基于队列的同步器

  3. AQS的使用方式通常都是通过内部类继承AQS实现同步功能,AQS是很多同步器的基础框架,我们还可以基于AQS,定制出我们所需要的同步器

  4. 在AQS内部,通过维护一个FIFO队列,来管理多线程的排队工作,在公平竞争的情况下,无法获取锁的线程会被封装成一个节点,置于AQS队列尾部,入队的线程通过自旋的方式获取锁,若在有限次的尝试后,仍未获取成功,线程会被阻塞住,后面有具体的过程说明

  5. state属性,表示资源的状态

    • 独占模式---只有一个线程能够访问资源,比如之前的监视器锁使某一个时间段只有一个线程能够访问资源

    • 共享模式----允许多个线程访问资源,但是有上限

      子类需要定义如何维护这个状态,控制如何获取锁和释放锁

image-20210909190349224

​ 用cas修改state状态,保证了修改state状态这个过程是原子性的。

  1. 提供了基于FIFO的等待队列,类似于Monitor的EntryList,Monitor是操作系统层面的,或者说是JVM层面的,EntryList是用C++来实现的,而AQS是纯Java实现的

  2. ReentrantLock是Java层面的锁

  3. 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于Monitor的WaitSet

  4. Lock是一个接口,ReentrantLock是一个Lock接口的实现类。

  5. 子类(继承于AbstractQueuedSynchronizer)主要实现这样一些方法(默认抛出UnsupportedOperationException)

    • tryAcquire
    • tryRelease
    • tryAcquireShared
    • tryRealeaseShared
    • isHeldExclusively

    image-20210909191200097

    image-20210909191210548

    自定义锁或ReentrantLock里定义的上锁、上锁带超时、上锁可打断、尝试上锁、解锁、多个条件变量等方法都是通过调用继承于AQS的子类定义的方法。

ReentrantLock原理

  1. image-20210909194450657
  2. ReentrantLock实现了Lock接口,内部也维护了继承于AQS的同步器Sync不过这个同步器Sync是抽象的还有两个继承
    • NonfairSync -- 非公平锁
    • FairSync -- 公平锁

非公平锁实现原理

  1. 构造器:

    默认通过非公平锁来实现内部维护的Sync抽象类

    image-20210909194836695

  2. NonfairSync这个类继承于Sync(ReentrantLock内部的抽象类)

  3. 原理:

    1. 没有竞争时,Thread-0成为非公平锁的Owner

      image-20210909201243193

    2. 第一个竞争出现时,首先尝试CAS,想要通过CAS将state置为1,但是目前state已是1,而这次调用cas的参数是期望值:0,更新值:1.

      所以期望值和当前对象的state属性不一样,更新失败,进入accquire

      image-20210909201251908

      image-20210909203507236

    3. 进入accquire方法之后,首先会再次尝试,会进行tryAccquire,这时state已是1,结果仍然是false

      然后进入addWaiter逻辑

      • 如果是非公平锁,那么进入tryAccquire,会抢占锁,抢占锁失败,才会封装成节点入AQS队列
      • 公平锁的tryAccquire比非公平锁多了一个条件,即!hasQueuedPredecessors(),检查AQS队列里是否有等待时间更长的线程节点
    4. 进入addWaiter逻辑,构造Node队列

      image-20210909203742017

      第一个Node称为哑元或哨兵,用来站位,并不关联线程。

      Node队列底层是通过双向链表实现

    5. AQS维护的线程节点同步等待队列中,头节点是成功获取到同步状态的节点。

    6. 只有前驱节点是头节点的线程节点才能够尝试获取同步状态。

      如果非首节点线程前驱节点出队或者被中断,则检查自己的前驱节点是否是头节点,如果是则尝试获取同步状态,如果不是当前线程节点则阻塞(park)。可以说是阻塞,也可以说是进入等待状态。

    7. 然后进入acquireQueued逻辑

      • acquireQueued会在一个循环中不断尝试获得锁,失败后进入park阻塞

      • 如果当前Node是第二位,那么再次tryAcquire尝试获取锁,state仍是1,失败

      • 进入shouldParkAfterFailedAcquire逻辑,将前驱node即head的waitStatus改为-1,这次返回false

        image-20210909204148130

      • shouldParkAfterFailedAcquire执行完毕后,回到acquireQueued,再次tryAcquire尝试获取锁,当然这时state仍为1,失败

      • 当再次进入shouldParkAfterFailedAcquire时,这时因为前驱node的waitStatus已经是-1,这次返回true

      • 进入parkAndCheckInterrupt,进入阻塞态。

        image-20210909204356085

        可以发现除了最后一个node,前面的node的waitStatus都被改成了-1,每个node都应该由上一个节点唤醒。

        -1表示-1的node有责任唤醒后继节点

    8. Thread-0 释放锁,进入tryRelease流程,如果成功

      • 设置exclusiveOwnerThread 为null
      • state = 0
    9. 当前Node队列不为null,并且head的waitStatus = -1,进入unparkSuccessor流程

      找到队列中离head最近的一个Node,unpark恢复其运行,本例中即为Thread-1

      • head指向Thread-1所在Node
      • 原本的head Node从链表断开,被垃圾回收器回收
    10. 这时候如果有其他线程来竞争(非公平的体现),比如Thread-4

      这个Thread-4可以和Thread-1竞争,可能Thread-1竞争锁又失败了。

    11. 非公平的意思就是说,即使排在Node队列(双向链表)的头位,仍然有可能不会获得锁

可重入原理

  1. 如果锁重入,让state++,说明同一线程获得了多次锁
  2. 释放锁的时候,也要让state减1(自减),只有state减为0,才释放成功。

可打断原理

  1. ReentrantLock默认是不可打断的
  2. 可打断,在park过程中如果被打断,采用了抛出异常的方式,不会再次进入for(;;)去竞争锁

公平锁原理

  1. 非公平锁只会检查state,不会检查AQS等待队列(在源码中可以发现只检查了state,没有做AQS队列的判断),不会管是否有线程对应的节点在AQS队列中等待,不会管AQS等待队列,直接就去竞争

    即使某个线程对应的节点在AQS等待队列的紧接着占位节点的下一个节点,也不一定会成功竞争到锁

  2. 而公平锁会检查AQS队列中是否有前驱节点,没有前驱节点,才去竞争。

    如果有前驱节点,该线程都不会去执行cas,都不会去比较state。

条件变量实现原理

  1. 每个条件变量对应于一个ConditionObject

    ConditionObject也维护了一个双向链表,作为那些不满足条件,需要休息的线程

  2. await流程

    1. 调用await之前,必须拥有锁才有资格调用

      await方法是ConditionObject的方法而不是Lock的方法

    2. 开始Thread-0持有锁,有资格调用await,然后调用await(),进入ConditionObject的addConditionWaiter流程

      创建新的Node状态为-2的节点,关联Thread-0,加入等待队列尾部

      image-20210909220203679

    3. 接下来进入AQS的fullRelease()流程,释放同步监视器上的锁

      image-20210909220242221

    4. unpark AQS队列中的下一个节点,竞争锁,紧接着占位节点之后的那个节点对应的线程竞争成功(如果是非公平锁的机制,但是没有其他线程来竞争的情况,或者是公平锁的机制)

      image-20210909220502184

      Thread-1节点的前一个节点(占位节点)要断开,然后head要指向新的节点即原占位节点的下一个节点成为新的占位节点,下一个竞争锁的节点就是当前head节点的下一个节点。在图中即Thread-2

    5. 在ConditionObject等待队列的线程节点阻塞

      image-20210909220707854

  3. AQS队列和ConditionObject等待队列的线程最后阻塞,都是通过调用park()阻塞的

  4. signal流程

    1. 假设现在占有锁的Thread-1要来唤醒ConditionObject等待队列中的线程,那么Thread-1要唤醒等待队列中的Thread-0

    2. 进入ConditionObject的doSignal流程,取得等待队列中第一个Node,即Thread-0所在Node

      image-20210910125940213

    3. 执行TransferForSignal流程,将等待队列中取得的Node,加入AQS队列尾部,将Thread-0的状态由-2改为0,将AQS队列中,Thread-0的前一个线程节点的状态改为-1,表示前一个线程有资格唤醒Thread-0这个线程

      image-20210910125952369

    4. 现在占有锁的Thread-1释放锁,进入unlock流程

公平与非公平

  1. 公平与非公平指的是线程获取锁的方式。公平模式下,线程在同步队列中通过 FIFO 的方式获取锁,每个线程最终都能获取锁。在非公平模式下,线程会通过“插队”的方式去抢占锁(也就是队列内的线程封装成的节点,就可以理解为线程,是按照队列FIFO顺序获得锁的,但是队列外的线程,就看它是否检查这个AQS队列了,如果不检查,就是非公平模式,队列外的线程就可以插队。),抢不到的则进入同步队列进行排队。默认情况下,ReentrantLock 使用的是非公平模式获取锁,而不是公平模式。不过我们也可通过 ReentrantLock 构造方法ReentrantLock(boolean fair)调整加锁的模式。

  2. 公平与非公平锁的优缺点:

    公平模式下,可保证每个线程最终都能获得锁,但效率相对比较较低,可以解决饥饿问题。

    非公平模式下,效率比较高,但可能会导致线程出现饥饿的情况。即使出现饥饿,也很少使用修改为公平锁的方式解决,而通过tryLock()的方式解决

  3. 为啥非公平模式抢了其他线程获取锁的机会,而整个程序的运行效率会更高呢?

    在激烈竞争的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。与此同时,如果 C 也请求这个锁,那么 C 很有可能会在 B 被完全唤醒前获得、使用以及释放这个锁。这样的情况时一种“双赢”的局面:B 获得锁的时刻并没有推迟,C 更早的获得了锁,并且吞吐量也获得了提高。(吞吐量是指用户线程执行时间占比,其他时间有垃圾回收时间,线程上下文切换消耗等)

    image-20210910183523679

  4. AQS 维护了一个基于双向链表的同步队列,线程在获取同步状态失败的情况下,都会被封装成节点,然后加入队列中。加入这个队列中,不代表线程封装成的节点阻塞了,加入这个队列,还会通过自旋的方式获得锁,多次(有限次)尝试失败后,才会通过park阻塞,并不是说入队就阻塞

    • **非公平锁的 lock 方法会首先尝试去抢占设置同步状态(通过CAS),而不是直接调用 acquire()方法 将线程放入同步队列中等待获取锁。**如果设置同步状态state失败,即CAS比较不成功,那么才进入acquire()

    • 公平锁的lock()则直接调用acquire()

    • 进入acquire()之后,先进行tryAcquire()

      • 公平锁的tryAcquire 多出了一个条件,即!hasQueuedPredecessors()。这个方法的目的是判断是否有其他线程比当前线程在同步队列中等待的时间更长。
      • 如果我们把 tryAcquire 中的条件!hasQueuedPredecessors()去掉,公平锁将不再那么“谦让”,它将会像非公平锁那样抢占获取锁,抢占失败才会入队,就变成了非公平锁。

ReentrantReadWriteLock

  1. ReentrantReadWriteLock和StampedLock都是读写锁

  2. ReentrantReadWriteLock支持重入,是可重入锁

  3. ReentrantReadWriteLock的目的是提高读操作的性能

    当读操作远远高于写操作时,这时候使用读写锁让读-读可以并发,提高性能。

    读读可以并发, 而读写互斥

线程安全集合类概述

    • 遗留的线程安全集合如Hashtable,Vector

      Hashtable是Map接口的实现类

      Vector是List接口的实现类

    • image-20210910130603196

    • JUC安全集合

      • BLOCKING类,大部分基于锁,并提供用来阻塞的方法。(这种称为悲观锁,乐观锁其实没有锁,是实现无锁并发,CAS+volatile实现)
      • CopyOnWrite类,适用于读多写少的场景
      • Concurrent类
  1. image-20210910130839000

  2. Concurrent类型的容器

    • 大多性能都比较高

      内部很多操作使用cas优化,一般可以提高较高吞吐量,用户线程执行的时间占比高。

      因为用cas+volatile实现的无锁并发,即乐观锁

    • 弱一致性

      当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历(因为不阻塞,没有上锁),这时内容是旧的

      求大小,因为弱一致性,并不一定准确

      对于非安全容器来讲,遍历时如果发生了修改,使用fail-fast机制也就是让遍历立刻失败,不再继续遍历

      Concurrent类型的容器是线程安全的

ConcurrentHashMap

  1. 内部方法跟普通HashMap方法一样,都是实现了Map接口

  2. 重要方法computeIfAbsent

    先要进行检查,如果缺少key,则计算生成一个值,然后将key value放入map

  3. HashMap在put过程中的原理

    先计算hashCode,找到底层Entry[]数组的一个位置进行存放,不同hashCode仍然可能放到一个位置

    如果此位置上的数据不为空(意味着此位置上存在一个或多个数据(多个数据以链表形式存在)),比较key1和已经存在的一个或多个数据的哈希值:

    • 如果key1的哈希值与已经存在的数据的哈希值都不相同,此时key1-value1这个Entry添加成功-----情况2
    • 如果key1的哈希值和已经存在的某一个数据的哈希值相同,那么继续比较,调用key1所在类的equals()方法:
      • 如果equals()返回false:此时key1-value1添加成功------情况3
      • 如果equals()返回true:使用value1替换相同key的value值。
  4. 要注意如果在底层Entry[]数组,元素被放在同一个位置,以链表形式存储,在JDK8,新加入的元素总是放在链表的尾部,在jdk7,新加入的元素总是放在链表的头部。

    在jdk7,新加入的元素总是放在链表的头部,这也是产生死链的重要原因,所以在jdk8进行了修改。

    死链是发生在扩容的时候

    扩容是往HashMap里put元素,当元素达到一个阈值时进行扩容,这个阈值是数组长度 * 加载因子,加载因子默认是0.75,扩容是扩容为原来的两倍底层Entry[]数组初始大小是16(或者说HashMap在底层初始大小是16,那么阈值是12,超过12时,就要开始扩容)

    ArrayList底层是Object[]数组,初始大小是10,如果不足10,第一次扩容,扩容为10,之后都扩容为原来大小的1.5倍

    而StringBuffer和StringBuilder扩容都是扩容为原来的2倍+2,底层数组初始大小是16.

    只要是数组扩容,都是会造一个新的数组,因为数组在底层的长度是创建时就确定了的,新建一个数组之后,将原来数组的数据复制过来。

    扩容之后,元素在新的数组又会分得更加均匀

  5. 扩容的时候会遍历原Entry[]数组的某个位置上的链表,遍历每一个Entry,把他们放到新的数组去

  6. HashMap在jdk7产生的死链问题:

    比如在Entry[]数组的某个下标位置,有一个链表是节点1-节点2-null,即头节点元素为节点1,下一个节点为节点2,再下一个节点为null

    此时线程Thread-0在执行put操作

    首先要明确死链是发生在扩容时,虽然JDK8改变了新节点加入链表的存储方式,解决了死链,但是还有其他问题,比如扩容丢数据,根本原因是因为在多线程环境下采用了HashMap这种不安全的集合

    那么此时如果发生扩容,并且这个扩容操作由另一个线程Thread-1来执行,假设扩容后节点1和节点2还在新数组(扩容都会造一个新数组)的同一个下标位置上,那么首先节点1会存储到这个下标位置,由于JDK7会将新加入的元素放在链表的头部而不是尾部,所以节点2会加入到链表的头部,那么在新数组的这个下标位置上,链表为节点2-节点1-null

    但是Thread-0记录了当前的e为节点1,next为节点2

    所以当前的e是节点1-null

    next是节点2-节点1-null

    所以此时Thread认为节点1指向节点2,但是实际上是节点2指向节点1,

    那么在下一次循环会发生将节点1-null移到新的数组的链表头

    e变为节点2-节点1-null

    next变为节点1-null

    再下一次循环会发生将节点2-节点1-null移到新的数组的链表头

    e变为节点1-null

    next变为null,形成死链

  7. 总结:

    jdk1.7HashMap在并发环境下,执行put操作时,会引起死循环,因为多线程会导致HashMap的Entry链表形成死链即环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环

  8. JDK8虽然将扩容算法做了调整,在扩容时,节点保持了和之前一样的顺序,新的节点被加入到链表的尾部,解决了死链问题(只有jdk7有死链问题,在扩容时会发生

    但JDK8仍不意味着在多线程环境下能够安全扩容,还会出现其他问题,在jdk8下,HashMap仍然是线程不安全的。

重要属性和内部类

  1. image-20210910144345408

    内部类class Node是指同一个下标位置上的链表

    ForwardingNode作为旧table的头节点,代表旧Entry[]数组的这个位置上的节点是处理过的!

    TreeBin指红黑树,是jdk8对HashMap底层的优化,在hash表的长度大于64和链表的长度大于8时,会采用红黑树来代替链表,可以提高查询效率,并且可以一定程度上防止DOS攻击(恶意造大量hashcode一样的对象往hashmap填充)

    链表的长度大于8时,会尝试把链表转换成红黑树,但是在转换之前,会尝试扩容,如果哈希表的长度没有达到64时,先不会把链表变为红黑树,先会扩容。当哈希表的容量扩容到64时,那么才会将链表转换成红黑树。

构造器

  1. image-20210910145407027

    initialCapacity是指初始容量

    • 初始容量达不到并发度时,会让初始容量 = 并发度
    • 设置的初始容量不一定是真正的初始容量,因为tableSizeFor要保证容量是2的n次方,最小是16,如果将初始容量设置为8,会通过这种计算方式将初始容量设置为16

    loadFactor是负载因子,默认为0.75

    concurrencyLevel 并发度

  2. ConcurrentHashMap有在table下标冲突的时候才会加锁,锁的不是整个table,锁的是这个链表的头节点

    这个地方保证了线程安全性。并且是细粒度的锁,保证了Entry[]数组其他元素还能被线程访问到。

    而HashMap则没有这个操作。

    在创建ConcurrentHashMap的时候也保证线程安全,只有一个线程能够创建

jdk7 ConcurrentHashMap

  1. 维护了一个segment数组,每个segment对应一把锁(分段锁)

    jdk7是把锁加在每个segment对象上,而jdk8是把锁加在链表的头节点

    缺点:

    • 不是懒惰初始化
    • Segment数组默认大小为16,容量初始化指定后就不能改变了,但是JDK8的Hash表是有扩容机制的。
  2. 每个segment对应于一个小的Hash表(HashEntry[]数组)每个Hash表里面又是数组加链表的结构

    一个segment对应于一个数组+链表结构。数组是HashEntry[]数组,数组里每个元素是一个链表,链表里每一个元素是HashEntry

  3. jdk7的HashMap在多线程环境下,进行put操作,会产生环形链表问题,会产生死链,造成死循环,jdk8虽然改变了这种put时,将节点插入到链表的头部改为了节点插入到链表的尾部,解决了死链问题,但是并不意味着jdk8下的HashMap能够在多线程环境下安全扩容,仍然有其他线程安全问题。根本原因是因为HashMap是线程不安全的

    所以想要采用线程安全的HashMap,应该采用ConcurrentHashMap

    而ConcurrentHashMap在jdk7和jdk8实现线程安全问题的方式是不同的

  4. ConcurrentHashMap在jdk7下是采用segment分段锁的方式实现的线程安全,在jdk8中是采用的CAS+Synchronized锁的方式实现的线程安全,这里的Synchronized只有在put的时候,对链表的头部节点进行上锁,并不是对整个Map进行上锁,采用的是细粒度锁,而且在jdk6中已经添加了Synchronized从偏向锁到轻量级锁到Monitor重量级锁的升级方式,所以效率有所提升

    segment扮演的锁的角色,实际是继承的ReentrantLock的方式

  5. 为什么采用ConcurrentHashMap,而不采用HashTable?

    HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率很低下,因为只有一把监视器锁,当然采用synchronized本来就应该保证只有一把监视器锁,所以后面采用的分段锁的方式。HashTable对get操作和put操作都要上锁,效率低下。ConcurrentHashMap的get操作是不需要上锁的,因为采用了volatile来修饰共享变量,所有线程在get这个读操作都能从主存中获得共享变量的最新值,不会导致读取错误的情况,涉及到内存屏障,所以get操作不需要上锁。

    只有一把监视器锁,导致了当一个线程访问HashTable的同步方法的时候,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态,既不能get也不能put。(连get()这种读操作都不可以,并且容易引起线程阻塞,进而造成线程的频繁上下文切换导致开销

  6. 所以ConcurrentHashMap在jdk7下是采用segment分段锁的方式实现的线程安全

    分段锁,通俗理解为有多个数据段,每个数据段对应一把锁,拿到某一个数据段的锁,就可以操作这个数据段的内容。

    当多个线程访问容器里不同数据段的数据时,线程间就不会产生锁竞争,提高效率

    首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用某一段的锁访问其中一个段的数据的时候,其他段的数据也能被其他线程访问。

  7. Segment是一种可重入锁---ReentrantLock,在ConcurrentHashMap里扮演锁的角色

  8. 一个Segment包含一个HashEntry数组,每个HashEntry是一个链表结构的元素

    就相当于一个Segment对应于一个小的Hash表,是数组加链表的结构

    当对HashEntry数组的数据进行修改的时候,必须首先获得与它对应的segment锁

    image-20210910170942805

  9. segment就可以直接理解为锁,分段锁,segment就是锁

  10. 既然ConcurrentHashMap使用分段锁segment来保护不同段的数据,那么必须通过散列算法定位到Segment

    这里的通过散列算法定位,不是用hashCode通过散列算法定位,是将hashCode再散列一次后得到新的hash值,再通过散列算法或者说映射算法定位。

    ConcurrentHashMap会首先使用Wang/Jenkins hash的变种算法对元素的hashCode进行再散列

  11. 进行再散列的原因:

    减少散列冲突,使元素能够均匀地分布在不同的Segment上,加入散列的质量差到极点,那么所有的元素都在一个segment中,这样也失去了分段锁的意义。

    再散列不是指的定位的过程,是指对hashCode再散列的过程,比如int hash = hash(key.hashCode())

    通过再散列之后的hash值,去定位。定位是通过散列算法或映射算法去定位

    • 得到hashCode值
    • 再散列得到新的hash值
    • 通过新的hash值,通过散列函数或者映射算法去定位到segment
  12. get()操作

    • 得到hashCode值
    • 再散列得到新的hash值
    • 通过新的hash值,通过散列算法定位到segment
      • 定位segment使用的是hashCode通过再散列后得到的值即第二步的新的hash值的高位通过散列算法进行定位
    • 再通过散列算法定位到元素
      • 定位HashEntry,使用的是第二步的hash值去通过散列算法去定位。

    HashTable容器的get方法是需要加锁的,ConcurrentHashMap的get操作不需要加锁,共享变量都定义成volatile类型,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值。这里的原理是内存屏障,面试的时候可以多说一说

  13. put()方法

    • 首先定位到segment,和之前get()方法的定位方式相同
    • get()可以不加锁,但是put()必须加锁,是由于put方法是对共享变量进行写入操作
    • 定位到segment之后,首先会尝试获取锁,如果获取失败肯定有其他线程竞争
    • 然后尝试自旋获取锁
      • 如果重试的次数达到了MAX_SCAN_RETRIES,则改为阻塞锁获取,保证能获取成功
    • 对定位到的segment的HashEntry判断是否需要进行扩容。(一个segment对应于一个HashEntry数组,数组的每个元素是链表结构的元素,链表的每个元素为HashEntry)

    ConcurrentHashMap只对某个Segment进行扩容而不会对整个Map进行扩容。

  14. size()方法

    ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个segment的大小,如果容器的count发生了变化,则再采用加锁的方式来统计所有segment的大小

  15. 用 volatile 修饰了 HashEntry 的数据 value 和 下一个节点 next,保证了多线程环境下数据获取时的可见性

    所以get操作不需要加锁,但是put操作是写操作,需要加锁,segment就是锁,需要先获得segment,才能对segment下的HashEntry进行下一步操作。

    image-20210910171404634

jdk8 ConcurrentHashMap

  1. 在数据结构上, JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的Node数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用 CAS + synchronized实现更加细粒度的锁。

    jdk7是把锁加在每个segment对象上,而jdk8是把锁加在链表的头节点

    jdk8将锁的级别控制在了更加细粒度的元素级别,只需要锁住链表头节点(如果是红黑树,那么锁住红黑树的根节点),就不会影响其他数组元素的读写,大大提高了并发度。

  2. put()

    1. 根据 key 计算出 hash 值;
    2. 判断是否需要进行初始化;
    3. 定位到 Node,拿到首节点 f,判断首节点 f:
      • 如果为 null ,则通过 CAS 的方式尝试添加;
      • 如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容;
      • 如果都不满足 ,synchronized 锁住 f 节点(这个地方的级别是对链表的头节点进行加锁,并不影响Entry[]数组的其他元素,大大提高了效率),判断是链表还是红黑树,遍历插入;
    4. 当在链表长度达到 8 的时候,数组扩容或者将链表转换为红黑树(扩容还是转换为红黑树,就看当前数组的长度是否是大于64,如果没有大于64,那么扩容,大于64,便转换为红黑树)。
  3. get()

    1. 根据 key 计算出 hash 值,判断数组是否为空;
    2. 如果是首节点,就直接返回;
    3. 如果是红黑树结构,就从红黑树里面查询;
    4. 如果是链表结构,循环遍历判断。

    get 方法和jdk1.7一样,同样不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,保证了共享变量的可见性,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。

    这也是它比其他并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 效率高的原因之一。

  4. 总结:

    jdk1.7和jdk1.8的ConcurrentHashMap的put()操作都是要加锁的,分别是分段锁(segment---ReentrantLock)和对链表头节点进行加锁。

    jdk1.7在put的时候会通过自旋获得锁,达到一定次数后,一定能获得锁

    jdk1.8在put的时候是对链表头部节点加锁,并不影响Entry[]数组的其他元素

    而get方法,在jdk1.7和1.8都不需要加锁,原因就是上面第四点说的原因

  5. JDK1.8 中为什么使用内置锁 synchronized替换 ReentrantLock?★★★★★

    • 在 JDK1.6 中,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
    • 减少内存开销 。假设使用ReentrantLock来获得同步支持(ReentrantLock实现原理是AQS抽象队列同步机制),那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
  6. ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?★★★

    我们先来说value 为什么不能为 null。因为 ConcurrentHashMap 是用于多线程的 ,如果ConcurrentHashMap.get(key)得到了 null ,这就无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,就有了二义性。

    而用于单线程状态的 HashMap 却可以用containsKey(key) 去判断到底是否包含了这个 null 。

  7. ConcurrentHashMap 的并发度是什么?★★

    并发度可以理解为程序运行时能够同时更新 ConccurentHashMap且不产生锁竞争的最大线程数。在JDK1.7中,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度,默认是16,这个值可以在构造函数中设置。

    如果自己设置了并发度,ConcurrentHashMap 会使用大于等于该值的最小的2的幂指数作为实际并发度,也就是比如你设置的值是17,那么实际并发度是32。如果小于16,那么实际并发度是16.

  8. ConcurrentHashMap 迭代器是强一致性还是弱一致性?★★

    与 HashMap 迭代器是强一致性不同,ConcurrentHashMap 迭代器是弱一致性。

    ConcurrentHashMap 的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历过的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。

  9. 为什么说JDK1.8 采用CAS+synchronized 保证线程安全?

    1. ConcurrentHashMap 在put时,定位到Node,如果节点为null,采用CAS的方式尝试添加
    2. put时,定位到Node,如果Node不为null,会锁住链表的头节点
    3. 定位到Node,首先需要根据key计算出hash值
  10. JDK1.7 与 JDK1.8 中ConcurrentHashMap 的区别?★★★★★

    • 数据结构:取消了 Segment 分段锁的数据结构,取而代之的是数组+链表+红黑树的结构,就没有segment分段这种说法了。

    • 保证线程安全机制:JDK1.7采用 Segment 的分段锁机制实现线程安全,其中 Segment 继承自 ReentrantLock 。JDK1.8 采用CAS+synchronized 保证线程安全。

    • 锁的粒度:JDK1.7 是对需要进行数据操作的 Segment 加锁,JDK1.8 调整为对每个数组元素加锁(Node)。

    • 链表转化为红黑树:定位节点的 hash 算法简化会带来弊端,hash 冲突加剧,因此在链表节点数量大于 8(且数据总量大于等于 64)时,会将链表转化为红黑树进行存储。

      • 将链表转换为红黑树之前,要判断能否进行扩容,如果能进行扩容,那么要先进行扩容,扩容到数组的容量是64之后,这时如果链表长度大于8,那么就将链表转换为红黑树。

        如果哈希表的长度没有达到64时,先不会把链表变为红黑树,先会扩容,扩容之后,会将原Entry[]数组的数据迁移,这个时候会根据新的容量重新进行散列存储。所以就不一定链表长度超过8,在这之后,超过8,那么转换为红黑树

    • 查询时间复杂度:从 JDK1.7的遍历链表O(n), JDK1.8 变成遍历红黑树O(logN)。

happens-before的几种规则

  1. 程序顺序规则

  2. 监视器锁规则---对一个锁的解锁,happens-before于随后对这个锁的加锁

  3. volatile变量规则---对一个volatile变量的写,happens-before于任意后续对这个volatile变量的读,涉及到内存屏障

  4. 传递性:如果A happens-before B, 且B happens-before C, 那么A happens-before C

Last Updated:
Contributors: 陈杨