一文梳理Java并发编程那些事儿

470 阅读1小时+

并发

最初的计算机只能接收一些特定指令,且形式为用户每输入一个指令,计算机就做出一个操作,用户在思考或输入时,计算机就等待,为了提高效率首先诞生了批处理操作系统。 批处理操作系统把一系列需要操作的指令记录形成清单,一次性交给计算机,计算机逐个执行这些程序。批处理操作系统的指令运行方式仍然是串行的,内存中始终只有一个程序在运行,前面的程序或许因为IO等因素阻塞导致后面程序的等待,为了提高效率诞生了进程的概念。

线程安全主要来源于JMM的设计,表现为主内存和工作内存不一致导致的内存可见性问题、编译器和处理器对程序和指令的重排序

进程与线程

  • 线程与进程的不同点:

    • 起源不同。先有进程后有线程。批处理操作系统独占CPU效率低,所以提出进程概念,内存中存在多个进程。由于处理器的速度远远大于外设,为了提升程序的执行效率,才诞生了线程。
    • 概念不同。进程是具有独立功能的程序运行起来的一个活动,是操作系统分配资源和调度的一个独立单位;线程是CPU的基本调度单位,并且CPU按照时间片轮转方式让线程轮询占用。
    • 内存共享方式不同。进程单独占有一定的内存地址空间,不同进程之间的内存数据一般是不共享的(除非采用进程间通信IPC);同一个进程中的不同线程往往会共享:
      1. 进程的代码段
      2. 进程的公有数据
      3. 进程打开的文件描述符
      4. 信号的处理器
      5. 进程的当前目录
      6. 进程用户ID和进程组ID
      7. 堆和方法区,堆存放实例化的对象,方法区存放JVM加载的类、常量及静态变量等信息
    • 拥有的资源不同。线程独有的内容包括:
      1. 线程ID
      2. 寄存组的值
      3. 线程的栈
      4. 错误的返回码
      5. 线程的信号屏蔽吗
    • 进程和线程的数量不同。
    • 线程和进程创建的开销不同。
      1. 线程的创建、终止时间比进程短
      2. 同一进程内的线程切换时间比进程短
      3. 同一进程的各个线程之间共享内存和文件资源,可以不通过内核进行通信。

    Java中没有协程的概念,协程往往指程序中的多个线程可以映射到操作系统级别的几个线程,Java中的线程数目与操作系统中的线程数目是一一对应的。

  • 上下文切换

    CPU通过为每个线程分配时间片来实现多线程机制,在从一个进程(或线程)切换到另一个进程(或线程)时,操作系统会保存上一个任务的状态(状态包括程序计数器、虚拟机栈中每个栈帧的信息诸如局部变量、方法参数、操作数栈、返回地址信息、锁记录),再下一轮时间片分配到线程时来恢复现场。

    上下文切换通常是计算密集(CPU密集)型的,意味着会消耗大量的CPU时间,故线程不是越多越好,对上下文切换的优化可以提升多线程性能。 上下文切换的具体代价:

    1. OS保存和恢复上下文产生时间开销
    2. 线程调度开销,即线程调度器根据一定规则确定占用处理器运行的线程
    3. 多核处理器,线程经过上下文切换之后到新的处理器核心上,产生处理器高速缓存重新加载的开销
    4. 上下文切换可能导致一级高速缓存被冲刷,即一级高速缓存数据被写入二级高速缓存和主内存

    产生上下文切换的条件:当前线程执行时间片用完,高于当前优先级的线程抢占,Java虚拟机的垃圾回收也有可能导致,线程自身调用了sleep、wait、join、yield、park、synchronized、lock方法。

    避免上下文切换:基于ConcurrentHashMap锁分段的无锁并发编程(不同线程处理不同段的数据,在多线程竞争条件下减少上下文切换的时间);CAS算法;创建较少的线程比如采用线程池复用线程;基于协程实现单线程多任务调度

  • 同步与异步

    同步是指被调用者不会主动告诉被调用者结果,需要调用者不断的去查看调用结果;同步机制控制线程访问对象的顺序 异步是指被调用者会主动告诉被调用者结果,不需要调用者不断的去查看调用结果;

    线程同步强调不同线程之间按照一定顺序执行,实现线程同步的方式:

      1. 多个线程共享同一把对象锁
      2. 基于Object类wait()、notify()和notifyAll()的等待通知机制。notify()随机唤醒任一个正在等待的线程,notifyAll()唤醒所有正在等待的线程,诸如在基于对象锁的环境下,lock.wait()让锁进入等待状态类似释放锁
      3. 多个线程之间的同步与通信使用信号量
      4. 主线程等待子线程调用结束基于join实现
      5. Thread.sleep方法释放CPU资源但不释放锁,wait方法释放cpu资源同时释放锁。调用Thread.sleep,线程由running状态变为timed waiting状态,其他线程调用interrupt方法唤醒睡眠状态并抛出异常
    
  • 线程的六种生命周期:

    1. NEW:已创建但未调用start()的线程状态
    2. RUNNABLE:可运行的。在调用了start()方法之后,线程便由NEW状态转换成Runnable状态。该状态可以被视作复合状态,包括子状态READY和RUNNING。执行Thread.yield()线程状态可能转换为READY,start()方法调用之后未分配CPU时间片为READY状态,得到CPU之后为RUNNING状态。如果没有调用任何的阻塞函数,线程只会在RUNNING和READY之间切换,也就是系统的时间片调度。
    3. BLOCKED:当前线程竞争其他线程持有独占资源(诸如锁)或者发起阻塞IO调用,线程进入此状态。
    4. WAITING:线程调用了Object.wait()、Thread.join()、LockSupport.park()方法就会由Rubbable状态进入Waiting状态;当线程调用了Object.notify()、Object.notifyAll()、LockSupportunpark()之后,线程由Waiting状态可能短时间进入Blocked状态然后进入Runnable状态或者直接进入Runnable状态或者因为发生异常直接进入Terminated状态。
    5. TIMEDWAITING:线程调用了Object.wait(time)、Thread.join(time)、LockSupport.parkNanos(time)、LockSupport.partUntil(time)、Tread.sleep(time)方法就会由Rubbable状态进TimedWaiting状态;当线程调用了Object.notify()、Object.notifyAll()、LockSupport.unpark()之后,线程由TimedWaiting状态可能短时间进入Blocked状态然后进入Runnable状态或者直接入Runnable状态或者因为发生异常直接进入Terminated状态。
    6. TERMINATED:线程的正常结束或者出现异常线程意外终止。

    竞争synchronized进入BLOCKED状态,竞争Lock接口的锁进入WAITING或者TimedWAITING状态。一个线程正常while循环内部逻辑、阻塞获取锁的逻辑都不会因thread.interrupt()而产生中断异常,只有在wait、sleep和join在调用thread.interrupt()产生中断异常。WAITING和TIMED_WAITING状态为轻量级阻塞,可以被中断;synchronized不能被中断,为重量级阻塞,处于BLOCKED状态。thread.interrupt本质上是唤醒轻量级阻塞,而不是中断一个线程。需要先拿锁才能wait,然后释放锁由其他线程唤醒之后再次拿锁,成功后才去执行后续逻辑,执行完毕退出同步区释放锁

  • 实现线程的方式

    • 实现Runnable接口相对于直接继承Thread类的优势

      1. 代码架构角度,不易于实现业务逻辑的解耦。run方法中作为所执行的任务应该与Thread类解耦。
      2. 新建线程的损耗,不易于实现线程池的优化
      3. Java不支持多继承,不易于实现扩展
      4. 继承Thread可以方便设置成员变量,使用Runnable只能使用外部final变量。
    • 异步模型Callable和Future

      无论是实现Runnable接口还是直接继承Thread,run方法都无返回值;此外为了使线程任务可以取消也采用Callable,配合异步返回的Future中cancel使用。配合FutureTask实现更有效的线程控制

      FutureTask在Executors框架体系中表示可获取结果的异步任务,实现了Future接口,提供了异步任务启动、取消、查询异步任务是否计算结束、获取最终异步任务的结果;此外,FutureTask实现了Runnable接口可交由Executor执行

  • 线程相关方法

    1. start()方法的本质是请求JVM来运行当前的线程,至于当前线程何时真正运行是由线程调度器决定的。start()方法的内部实现主要是包括三个步骤:一是检查要启动的新线程的状态,二是将该线程加入线程组,三是调用线程的native方法start0()。具体来说,start方法判断threadStatus是否为新建态0,如果不是直接抛出异常避免start被多次调用
    2. 线程的正确停止方法是:使用interrupt()来通知,而不是强制结束指定线程。
    3. wait()方法:当线程A执行object.wait()时,线程A持有的锁会释放,此时其他线程获取到object锁;其他线程代码中执行了object.notify()方法时,线程A会重新获取到object锁,可以进行线程的调用。注意notify()、notifyAll()方法必须要在wait()方法之后调用,若顺序改变则程序会进入永久等待。调用object.wait()时要通过synchronized关键字使object同步,在wait()的内部,会先释放锁object并进入阻塞状态,之后被另外一个线程用notify()唤醒而重新拿锁。

    wait与sleep的不同:

    1. wait方法可以指定时间参数也可以不指定,sleep方法必须指定时间参数

    2. wait方法释放CPU资源并且释放锁,sleep方法释放CPU资源但不释放锁所以易死锁

    3. wait必须放到同步块或者同步方法中,sleep可以在任意位置调用

    4. park()方法:在线程中调用LockSupport.park()进行线程的挂起,在其他线程中调用LockSupport(已挂起的线程对象)进行线程的唤醒。park()和unpark()是基于许可证的概念存在的,只要调用了unpark()在一次park()中就可以实现线程的一次唤醒(这里的一次是指线程只要调用了park()就要调用unpark(),不能实现调用多次unpark()后面的park()多次调用就可以直接实现线程的唤醒),park()和unpark()没有调用顺序的限制。 注意park()、unpark()方法不是基于监视器锁实现的,与wait()方法不同,park()只会挂起当前线程并不会对锁进行释放。在线程中使用synchronized关键字的内部调用了park()容易导致死锁。

    5. 能够被中断的阻塞称为轻量级阻塞,对应的线程状态是WAITING或者TIMED_WAITING;而像synchronized 这种不能被中断的阻塞称为重量级阻塞,对应的状态是 BLOCKED

    6. threadA.join():线程A独占CPU其他线程此时暂停直到threadA执行完毕。

    7. threadA.yield():线程A让出CPU的占用,可能导致线程A的暂停,但即便如此,线程A仍有机会继续运行

考虑JAVA并发编程的线程安全时,从两个核心、三条性质入手

JMM与happens-before规则作为两个核心;原子性、内存可见性与重排序作为三条性质。synchronized保证了原子性,有序性(线程访问共享变量时只能串行执行)和内存可见性;volatile禁止指令重排序实现了有序性,在指令中添加Lock前缀实现内存可见性

  • 并发编程模型的两个关键问题:
  1. 控制不同线程间操作发生的相对顺序,即线程同步
  2. 线程之间消息交换,即线程通信
  • 解决以上两个问题有两类方式:
  1. 消息传递并发模型:线程之间无公共状态,通过消息传递实现实现线程间显式通信,由于发送消息总是在接收消息之前,所以线程之间同步。

  2. 共享内存并发模型:线程之间通过写-读内存中公共状态进行隐式通信,需要显式实现代码在线程间的互斥执行。

  • JMM概念模型
    1. Java所有变量都存储在主内存中
    2. 每个线程都有自己独立的工作内存,里面保存该线程的使用到的变量副本(该副本就是主内存中该变量的一份拷贝)。虚拟机栈、本地方法栈以及程序计数器不会在线程之间共享,不存在内存可见性问题,本地内存是Java内存模型的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器等,每个线程有自己的栈资源用于存储线程的局部变量。
    3. 线程对共享变量的操作都是在自己的内存中完成,而不是在主内存中完成。
    4. 线程对共享变量的操作默认情况下在其他线程中不可见,可以通过将本地线程的变量同步到共享内存中之后将共享变量同步到其他的线程

数据一致性问题及解决方案

多个主体(多个服务器节点或者多个线程)对同一份数据无法达成共识,产生数据一致性问题

  1. 排队,即同步,每个操作按照顺序执行,后面的操作基于之前操作达到数据一致。锁、互斥量、管程、屏障等等都是排队思想。排队的缺点是性能低下。

  2. 投票,多个人同时进行操作但最终谁修改成功以投票结果确定。缺点是受到欺诈、网络中断影响。案例是Paxos和Raft算法。

  3. 避免,一个是类似git的版本控制及冲突解决来应对数据一致性问题,一个是ThreadLocal每个线程维护独立的数据副本,volatile直接从主内存读取数据并将数据直接刷新到主内存

happens-before规则

JMM未同步程序与顺序一致性模型的执行差别

  1. 顺序一致性保证单线程内操作按程序顺序执行,JMM实现重排序不保证单线程内操作按程序顺序执行

  2. 顺序一致性保证多线程看到一致的操作执行顺序,JMM不保证因为JMM不保证所有操作立即可见

  3. 顺序一致性保证对所有的内存读写操作都具有原子性,JMM不保证对64位的long型和double型变量的写操作具有原子性(32位机器对一个64位变量的写入被拆分成两个32位的写操作来执行)

  4. JMM基于happens-before定制两个操作之间执行顺序,这两个操作可以在一个线程内或者不同线程间,保证了跨线程的内存可见性。

  5. 如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的,不管它们在不在一个线程。

  6. 如果操作A happens-before操作B且JMM经过重排序之后的结果与按happens-before关系来执行的结果一致,那么操作的具体执行可以不必按照 happens-before顺序进行。

  • 常见happens-before原则: 1. 单线程中的每个操作,happen-before于该线程中任意后续操作。 2. 对volatile变量的写,happen-before于后续对这个变量的读。 3. 对synchronized的解锁,happen-before于后续对这个锁的加锁。 4. 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的读。

几个常见特性: 原子性、内存可见性和重排序

  1. 原子性:
    原子(Atomic)操作指相应的操作是单一不可分割的操作。 在多线程中,非原子操作可能会受到其他线程的干扰,使用关键字synchronized可以实现操作的原子性。synchronized的本质是通过该关键字所包括的临界区的排他性保证在任何一个时刻只有一个线程能够执行临界区中的代码,从而使的临界区中的代码实现了原子操作。 Java中,对基本数据类型的读取和赋值操作是原子性操作,所谓原子性操作就是指这些操作是不可中断的,要做一定做完,要么就没有执行。 比如: i = 2; // 原子操作 j = i; // 先读取i后赋值j,非原子操作 i++; // 先读取i,后加1,后赋值并写回主存,非原子操作 i = i + 1;// 先读取i,后加1,后赋值并写回主存,非原子操作 JMM只实现了基本的原子性,像上面i++那样的操作,必须借助于synchronized和Lock来保证整块代码的原子性了。线程在释放锁之前,必然会把i的值刷回到主存的。

    原子性,软件层面采用synchronized,硬件层面采用CAS操作。volatile可以使long以及double的写入原子性,但是不会保证++这类操作原子性。

  2. 内存可见性:

    CPU在执行代码时,为了减少变量访问的时间消耗会将代码中访问的变量值缓存到CPU的缓存区中,尽管通过MESI缓存一致性协议可以使多级缓存同步,但在计算单元和L1之间加了Store Buffer、Load Buffer仍然存在不同步的现象。代码在访问某个变量时,相应的值会从本地缓存中读取而不是在主内存中读取;同样的,代码对被缓存过的变量的值的修改可能仅仅是写入本地缓存区而不是写回到猪内存中。这样就导致一个线程对相同变量的修改无法同步到其他线程从而导致了内存的不可见性。 可以使用synchronizedvolatile来解决内存的不可见性问题。两者又有点不同。synchronized仍然是 通过将代码在临界区中对变量进行改变,然后使得对稍后执行该临界区中代码的线程是可见的。volatile不同之处在于,一个线程对一个采用volatile关键字修饰的变量的值的更改对于其他使用该变量的线程总是可见的,它是通过将变量的更改直接同步到主内存中,同时其他线程缓存中的对应变量失效,从而实现了变量的每次读取都是从主内存中读取。volatile变量读取伴随刷新处理器缓存,写入伴随冲刷处理器缓存。

    虽然一个处理器的高速缓存内容不能被另一个处理器直接读取,但一个处理器可以通过缓存一致性协议读取其他处理器中高速缓存中数据。一个处理器从其他存储部件读取数据并缓存到高速缓冲中的过程被称为缓存同步,缓存同步保证了可见性。

    将对数据更新落实到高速缓存或主内存而不是写缓冲区中的过程叫做冲刷处理器缓存,一个处理器在数据更新之后从其他处理器的高速缓存或主内存进行缓存同步的过程叫做刷新处理器缓存。

  3. 重排序: 一个好的内存模型放松了对处理器和编译器规则的限制,在不改变程序执行结果的前提下尽量提高并行度,此时编译器和处理器常常对指令进行重排序

    1. 编译器优化的重排序。在不改变单线程程序语义的前提下,编译器重新调整语句的执行顺序。 JMM编译器重排序规则会禁止一些特定类型的编译器重排序。
    2. 指令级并行的重排序。CPU流水线技术,利用指令级并行技术将多条指令重叠执行。在指令级别,让没有依赖关系的多条指令并行。 生成指令序列的时候插入内存屏障。
    3. 内存系统的重排序。Store Buffer的延迟写入是重排序的一种,称为内存重排序。CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。引起了了内存可见性

    2,3都作为处理器对指令的重排序优化

  4. JMM通过happens-before规则向用户提供跨线程的内存可见性保证

    1. as-if-serial语义:编译器和CPU遵守了as-if-serial语义,保证每个线程内部都是"看似完全串行的",单线程程序执行结果不改变,用户不必担心单线程中的重排序问题和内存可见性问题。
    2. happens-before: 单线程中的每个操作,happen-before 对应该线程中任意后续操作(也就是 as-if-serial语义保证);对volatile变量的写入,happen-before对应后续对这个变量的读取;对synchronized的解锁,happen-before对应后续对这个锁的加锁
    3. 指令的重排序导致代码的执行顺序改变,这经常会导致一系列的问题,比如在对象的创建过程中(单例双检锁),指令的重排序使得我们得到了一个已经分配好的内存而对象的初始化并未完成,从而导致空指针的异常。volatile关键字可以禁止指令的重排序从而解决这类问题,避免构造函数泄露问题(构造对象过程不是原子的,其包括分配内存,初始化成员变量和对象引用赋值三步)
    4. synchronized可以保证在多线程中操作的原子性和内存可见性,但是会引起上下文切换;而volatile关键字仅能保证内存可见性,但是可以禁止指令的重排序,同时不会引起上下文切换。 synchronized 只是多个线程有序访问代码,进入到代码内部结构是无法控制程序顺序执行的,但是同一把锁的获取和释放满足happens-before, volatile 内存原语有禁止重排序,所以可以控制
  5. 对比happens-before和as-if-serial,二者都是为了在不改变程序执行结果的前提下,尽可能提高程序执行的并发度

    1. as-if-serial保证了单线程内程序的执行结果不会改变,给用户带来的幻境表现为单线程程序是按照程序的顺序来执行的

    2. happens-before保证了正确同步的多线程程序的执行结果不会改变,给用户带来的幻境表现为正确同步的多线程程序的执行结果是按照happens-before顺序来执行的

  6. 内存屏障 为了禁止编译器重排序和CPU内存重排序,在编译器和CPU内存层面都有对应的指令,即内存屏障。编译器的内存屏障保证在程序编译过程中不对指令进行重排序;

    CPU内存屏障的分类:

    1. LoadLoad:禁止读和读的重排序。
    2. StoreStore:禁止写和写的重排序。
    3. LoadStore:禁止读和写的重排序。
    4. StoreLoad:禁止写和读的重排序

    volatile与内存屏障:

    1. 在每个volatile写操作前插入一个StoreStore屏障;
    2. 在每个volatile写操作后插入一个StoreLoad屏障;
    3. 在每个volatile读操作后插入一个LoadLoad屏障;
    4. 在每个volatile读操作后再插入一个LoadStore屏障。

    作用:

    1. 阻止屏障两侧的指令重排序;
    2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。

锁的持有线程在锁获得与锁释放期间执行的代码称为临界区,共享数据只能在临界区中访问,临界区一次只能被一个线程执行。锁作为一种排他性资源、作为一种互斥同步手段、作为一种阻塞同步手段在多线程竞争时导致上下文切换

  • 原子性、可见性的保障

    1. 锁通过互斥(即同一把锁一次只能被一个线程持有,临界区代码一次只能由一个线程访问)保障原子性,临界区操作具有不可分割的原子特性;
    2. 锁的获得隐含着刷新处理器的动作,锁的释放隐含着冲刷处理器的动作,刷新处理器指的是读线程在执行临界区代码之前将写线程对共享变量的更新同步到高速缓存中,冲刷处理器指的是写线程在执行临界区代码之后将数据更新同步到下一个锁持有的线程中;
    3. 锁的互斥与可见保证临界区代码读取到共享变量最新值,对于引用类型数据,锁保证读取引用变量的最新值。
  • CAS

    未优化前的synchronized是阻塞同步的,在出现线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题;CAS是非阻塞同步的,不是武断的将线程挂起而是操作失败后进行一定的尝试,避免了线程阻塞和唤醒带来的开销

    存在的问题:

    1. ABA问题。AtomicStampedReference通过添加版本号解决
    2. 自旋时间过长。虽然非阻塞同步CAS不会将线程挂起,但是死循环自旋进行尝试占用大量CPU资源。基于pause指令使自选失败的cpu睡眠一小段时间再自旋
    3. 只能保证一个共享变量的原子操作。多个变量的CAS操作需要封装对象,并使用AtomicReference保证引用对象之间的原子性
  • 公平与非公平

    如果一个锁是公平的,那么锁的获取顺序应该符合请求上的绝对时间顺序,即FIFO。公平锁每次都是从同步队列的第一个结点获取锁;公平锁为了保证请求上的绝对顺序需要频繁的上下文切换,导致性能开销

    非公平锁有可能刚释放锁又获取锁,产生饥饿现象

  • 读写锁

    读写锁允许同一时刻被多个读线程访问,在写线程访问时,所有的读线程与其他的写线程都会被阻塞。

内部锁

监视器锁又叫内部锁即synchronized,是一种排他锁也是一种非公平锁。

  1. synchronized修饰方法,或者代码块。作为锁句柄的变量通常用private final修饰避免锁句柄修改而产生的竞态。

  2. synchronzied获取和释放都是自动的,所以被称为内部锁,内部锁的使用不会导致锁泄露,java编译器在将同步代码块编译为字节码的时候对临界区抛出的异常进行特殊处理。

  • 内部锁的调度:

    Java虚拟机为每个内部锁分配一个入口集,记录等待获取内部锁的线程,多个线程申请锁时只有一个线程申请成功,其他申请失败的锁暂停并进入BLOCKED状态并存入相应锁的入口集中等待再次申请锁,这些线程又被称为相应内部锁的等待线程。Java虚拟机如何从一个锁的入口集中选择一个等待线程作为申请锁的线程与JVM具体实现有关。

    具体说来:

    每个Java对象都存在一个与之关联的监视器monitor,线程对monitor的持有方式以及持有时机决定了synchronized的锁状态以及升级方式,monitor由C++的ObjectMonitor实现,ObjectMonitor主要维护WaitSet与EntrySet两个队列来保存ObjectWaiter对象,此外还有一个_owner字段记录已经获得对象monitor的线程id, 一个计数器加1。每个阻塞等待获取锁的线程封装成ObjectWaiter入队,多个线程获取锁时会进入EntrySet队列且变为BLOCKED状态,_owner字段和计数器字段值被设置;调用了wait(),当前线程进入WaitSet等待被唤醒。

  • 内部锁的实现:

    在jdk1.6中,为了提高获得锁与释放锁的效率,提供了无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态四种状态。根据竞争情况的逐渐升级,锁也会升级;锁降级发生条件较为苛刻,锁降级发生在Stop The World期间,当JVM进入安全点时会检查是否有闲置的锁然后降级。

    Java的锁都是基于对象的,非数组类型采用2个字宽来存储对象头,数组类型采用3个字宽来存储对象头。对象头包括:

    1. Mark Word存储对象的hashCode和锁信息
    2. Class Metadata Address存储到对象类型数据指针
    3. Array length如果是数组存储数组长度
  • 锁状态:

    1. 大多数情况下,锁不存在多线程竞争,通常同一把锁总会被同一线程多次获得。偏向锁在无资源竞争的情况下消除了同步语句,连CAS都不做了提升了程序的性能。

      一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程ID。当下次该线程进入这个同步块时,会去检查锁的Mark Word里面是不是放的自己的线程ID。如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁 ;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用CAS来替换Mark Word里面的线程ID为新线程的ID,这个时候要分两种情况:成功,表示之前的线程不存在了, Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

      偏向锁使用了一种等到竞争出现才释放锁的机制,当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

    2. 多个线程在不同时段获取同一把锁,不存在锁竞争的情况,没有线程阻塞,JVM采用轻量级锁来避免线程的阻塞与唤醒。JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间为Displaced Mark Word。如果一个线程获得锁时发现为轻量级锁,会将锁的Mark Word复制到自己的Displaced Mark Word中。然后线程尝试用CAS将锁的Mark Word替换为指向锁记录的指针,如果成功当前线程获得锁,如果失败表示MarkWord已经被替换成了其他线程的锁记录,说明在于其他线程竞争锁,当前线程采用自旋的方式来获取锁,自旋浪费CPU,所以一般指定自旋次数然后进入阻塞状态,同时锁进入重量级锁状态。

      释放时,当前线程基于CAS将Displaced Mark Word中的内容复制回锁的Mark Word里,如果没有发生竞争那么这个复制操作会成功;如果此时存在其他线程自旋多次导致轻量级锁升级成重量级锁,CAS操作失败,释放锁并唤醒阻塞的线程

    3. 重量级锁依赖操作系统的互斥量mutex实现,操作系统中线程间状态转换需要较长的时间,所以重量级锁效率低,但被阻塞的线程不会消耗CPU

  • 锁升级:

    1. 线程在准备获取共享资源时: 第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。

    2. 如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。

    3. 两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作, 把锁对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord。

    4. 第三步中成功执行CAS的获得资源,失败的则进入自旋 。

    5. 自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。

    6. 进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

显式锁

与内部锁相对的就是显式锁,Java的Lock接口以及相应实现类ReetrantLock即显式锁。与内部锁相比,显式锁需要考虑锁泄露的问题,线程转储的时候不包含显式锁的相关信息,定位问题比较困难。对于多数线程持有一个锁时间相对长或者线程申请锁平均时间间隔较长的情况下使用公平锁即显式锁。

  • ReetrantLock

  • ReetrantReadWriteLock

    同步状态的低16位用来表示写锁的获取次数,同步状态的高16位用来表示读锁的获取次数。

    在锁获取方法tryAcquire中,当读锁已经被读线程获取或者写锁已经被其他写线程获取,写锁获取失败;否则获取成功并支持重入、增加写状态。

锁实现原理

  1. 需要一个state变量,标记该锁的状态。对state变量的操作,要确保线程安全,也就是会用到CAS。state为0表示没有持有锁,否则表示持有锁的重入次数。

  2. 需要记录当前是哪个线程持有锁。

  3. 底层支持对一个线程进行阻塞或唤醒。Unsafe.park和Unsafe.unpark(Thread t)实现线程阻塞以及唤醒阻塞的线程t,而LockSupport对这对原语操作实现了封装。

  4. 需要有一个队列维护所有阻塞的线程。这个队列也必须是线程安全的无锁队列,也需要用到CAS。

    AQS中利用双向链表和CAS实现了一个阻塞队列。head指针指向双向链表头部,tail指针指向双向链表尾部,入队是将Node加到tail尾部并对tail执行CAS操作,出队对head进行CAS操作并把head指针后移,每一个Node承载一个阻塞线程。

避免死锁

  1. 避免一个线程同时获取多把锁
  2. 避免一个线程在锁内部占有多个资源,保证每个锁只占用一个资源
  3. 使用lock.tryLock(timeOut),设置获取锁的超时时间,大于超时时间时获取锁失败且线程不会阻塞
  4. 线程加的锁和解的锁必须是同一把。对于数据库锁,加锁和解锁必须在一个数据库连接里

锁状态

一个对象存在4种锁状态,Java锁基于对象且锁信息存放在对象头中,非数组类型用2个字宽存储对象头,数组类型用3个字宽存储对象头。对象头主要包括:Mark Word(存储对象的hashCode信息和锁信息)、Class Metadata Address(存储到对象类型数据的指针)、Array length(如果是数组存在这个部分,存储数组长度)

  1. 无锁
  2. 偏向锁:MARK WORD存储偏向的线程ID
  3. 轻量级锁:指向线程栈中锁记录的指针
  4. 重量级锁:指向互斥量(重量级锁)的指针。

无锁编程

  1. 一写一读的无锁队列:内存屏障。一写一读的无锁队列即Linux内核的kfifo队列,一写一读两个线程,不需要锁,只需要内存屏障
  2. 一写多读的无锁队列:volatile关键字。Disruptor的RingBuffer之所以可以做到完全无锁,也是因为"单线程写",具体来说,就是RingBuffer有一个头指针,对应一个生产者线程;多个尾指针对应多个消费者线程。每个消费者线程只会操作自己的尾指针。所有这些指针的类型都是volatile变量,通过头指针和尾指针的比较,判断队列是否为空。
  3. CAS也是CPU提供的一种原子指令。基于CAS和链表,可以实现一个多写多读的队列。具体来说,就是链表有一个头指针head和尾指针tail。入队列,通过对tail进行CAS操作完成;出队列,对head进行CAS操作完成
  4. ConcurrentSkipListMap基于无锁链表实现的并发跳查表。

volatile:

被volatile修饰的变量保证每个线程获取到该变量的最新值,避免出现数据脏读的现象

  1. volatile最主要的就是实现了共享变量的内存可见性,其实现的原理是:volatile变量的值每次都会从高速缓存或者主内存中读取,对于volatile变量,每一个线程不再会有一个副本变量,所有线程对volatile变量的操作都是对同一个变量的操作。volatile变量的开销包括读变量和写变量两个方面。volatile变量的读、写操作都不会导致上下文的切换,因此volatile的开销比锁小。但是volatile变量的值不会暂存在寄存器中,因此读取volatile变量的成本要比读取普通变量的成本更高。

  2. volatile常被称为"轻量级锁",但是如果在程序代码中想要保证某个变量在多个操作之间的实时准确性,volatile还是不如锁,其实这也算是原子性的部分;volatile不能保证原子性,要是说能保证,也只是对单个volatile变量的读/写具有原子性,但是对于类似volatile++这样的复合操作就无能为力了。比如说并发情况下,多个线程在操作volatile变量,A线程读取了但是还没来得及写入,B线程读取了也还是旧数据,此时A和B两个线程的操作会叠加。

  3. 注意单个变量的多线程操作共享还是可以用volatile替代锁的。

  4. volatile一个是解决了long或double在非64位机器上写入非原子性的问题,一个是解决了单例模式双检锁实现中常遇到的构造函数溢出问题即可能在内存分配之后,引用指向先于初始化成员变量导致一个线程拿到的对象是未完全初始化的。

  • volatile关键字如何满足并发编程的三大特性的?

    由于对一个volatile域的写,happens-before于后续对这个volatile域的读,如果一个变量声明成是volatile的,那么当我读变量时,总是能读到它的最新值。此外volatile关键字通过

  • volatile底层的实现机制? 如果把加入volatile关键字的代码和未加入volatile关键字的代码都生成汇编代码,会发现加入volatile关键字的代码会多出一个lock前缀指令。lock前缀指令实际相当于一个内存屏障,内存屏障提供了以下功能:

    1. 重排序时不能把后面的指令重排序到内存屏障之前的位置。在volatile变量的写操作前面和后面分别使用StoreStore屏障与StoreLoad屏障保证volatile写操作不会和之前的普通写操作、之后的volatile读写操作重排序。在每个volatile读操作之后插入LoadLoad屏障和LoadStore屏障禁止了读操作不会与下面的普通读或写重排序 volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障

    2. Lock前缀的指令会引起处理器缓存写回内存,一个处理器的缓存回写到内存会导致其他处理器的缓存失效。基于缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态;

    3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

  • volatile的使用场景

    1. 状态量标记
    2. 懒汉单例模式,双检锁避免指令重排序

并发容器

ThreadLocal(无同步保证线程并发安全)

ThreadLocal。即线程变量,其包含一个以ThreadLocal对象为键、任意对象为值的Map。ThreadLocal被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。

静态变量可能导致线程安全问题,一般可以声明静态ThreadLocal变量。使用threadlocal.set将对象实例保存在每个线程自己拥有的threadlocalmap中,每个线程使用自己的对象实例

场景:

  1. Web开发中,独立线程保存当前请求中的用户信息
  2. JDBC对于同一个线程下的请求,都会返回同一个链接
  3. 对于调用链路比较长的线程,ThreadLocal可以存储每个环节下出现的异常方便后续链路的调用

ThreadLocal采用的是上述策略中的第一种设计思想——采用线程的特有对象.采用线程的特有对象,我们可以保障每一个线程都具有各自的实例,同一个对象不会被多个线程共享,ThreadLocal是维护线程封闭性的一种更加规范的方法,这个类能使线程中ThreadLocal与保存值的对象关联起来,从而保证了线程特有对象的固有线程安全性。

ThreadLocal类相当于线程访问其线程特有对象的代理,即各个线程通过这个对象可以创建并访问各自的线程特有对象,泛型T指定了相应线程持有对象的类型。一个线程可以使用不同的ThreadLocal实例来创建并访问其不同的线程持有对象。多个线程使用同一个ThreadLocal实例所访问到的对象时类型T的不同实例。

ThreadLocal提供了get和set等访问接口或方法,这些方法为每一个使用该变量的线程都存有一份独立的副本,因此get总是能返回由当前执行线程在调用set时设置的最新值。其主要使用的方法如下:

	public T get(): 获取与当前线程中ThreadLocal实例关联的线程特有对象,如果不覆盖initialValue方法,第一次get将返回null,

	`
		// 使用方法,只需声明公共static变量,然后直接在子线程调用get set即可,jvm会自动关联
		public DateFormat getDateFormat() {
			DateFormat df = threadLocal.get();
			if (df == null) {
				df = new SimpleDateFormat(DATE_FORMAT);
				threadLocal.set(df);
			}
			return df;
		}

	`

	public void set(T value):重新关联当前线程中ThreadLocal实例所对应的线程特有对象,通过set设置的值,只有当前线程自己看得见,这意味着你不可能通过其他线程为它初始化值。为了弥补这一点,ThreadLocal提供了一个withInitial()方法统一初始化所有线程的ThreadLocal的值。
	protected T initValue():如果没有调用set(),在初始化threadlocal对象的时候,该方法的返回值就是当前线程中与ThreadLocal实例关联的线程特有对象。当使用了threadlocal.set方法之后就不会执行初始化方法了
	public void remove():删除当前线程中ThreadLocal和线程特有对象的关系。
  • TreadLocal如何做到线程隔离的

    每个线程Thread都维护了自己的threadLocals变量,所以在每个线程创建ThreadLocal的时候,实际上数据是存在自己线程Thread的threadLocals变量里面的,别人没办法拿到,从而实现了隔离。每一个Thread都会维护一个ThreadLocalMap对象,ThreadLocalMap是一个类似Map的数据结构,但是它没有实现任何Map的相关接口。ThreadLocalMap是一个Entry数组,每一个Entry对象都是一个"key-value"结构,而且Entry对象的key永远都是ThreadLocal对象。当我们调用ThreadLocal的set方法时,实际上就是以当前ThreadLocal对象本身作为key,放入到了ThreadLocalMap中。

  • ThreadLocalMap

    1. Thread的独有变量存放在ThreadLocal.ThreadLocalMap threadLocals中,ThreadLocalMap冲突解决是线性探测,HashMap是拉链法。用数组是因为,我们开发过程中可以一个线程可以有多个TreadLocal来存放不同类型的对象的,但是他们都将放到你当前线程的ThreadLocalMap里,所以肯定要数组来存。

    2. 可能发生内存泄露:

    ThreadLocalMap.Entry的key即ThreadLocal属于WeakReference类型,而value属于强引用类型(与线程同存在,value为当前线程的局部变量的副本的值),ThreadLocalMap中的key即ThreadLocal是弱引用,当不存在外部强引用的时候,就会自动被回收,但是由于value和线程的生命周期一致,如果线程不销毁(线程池内线程一直存在),value也不会被回收就会产生内存泄露。解决内存泄漏的最有效方法就是,在使用完ThreadLocal之后,要注意调用threadlocal的remove()方法释放内存。

    注意,调用get方法时,如果当前key的threadlocal不存在,那么也会将value释放;但有个问题就是如果我们一直在访问threadlocal存在的threadlocalmap那么就还是会发生内存泄露,调用remove是唯一的解决方案。

    1. 如果是子线程需要访问父线程的ThreadLocal,那么需要使用InheritableThreadLocal,但即便子线程可以访问父线程传递的数据,也需要注意:

      1. 变量的传递是发生在线程创建的时候,如果不是新建线程,而是用了线程池里的线程,就不灵了
      2. 变量的赋值就是从主线程的map复制到子线程,它们的value是同一个对象,如果这个对象本身不是线程安全的,那么就会有线程安全问题
      3. 对于使用线程池的情况,由于会缓存线程,线程是缓存起来反复使用的。这时父子线程关系的上下文传递,已经没有意义
      4. 采用阿里的TTL
    2. 与对象存储不同,在Java中,栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存,而堆内存中的对象对所有线程可见,堆内存中的对象可以被所有线程访问。ThreadLocal实例实际上也是被其创建的类持有(更顶端应该是被线程持有),而ThreadLocal的值其实也是被线程实例持有,它们都是位于堆上,只是通过一些技巧将可见性修改成了线程可见。

  • ThreadLocal在Spring中的应用

    Spring采用Threadlocal的方式,来保证单个线程中的数据库操作使用的是同一个数据库连接,同时,采用这种方式可以使业务层使用事务时不需要感知并管理connection对象,通过传播级别,巧妙地管理多个事务配置之间的切换,挂起和恢复。

  • ThreadLocal在Netty中的应用

    FastThreadLocal,对于ThreadLocalMap的线性探测进行了修改。由于Netty对ThreadLocal的使用非常频繁,Netty对它进行了专项的优化。它之所以快,是因为在底层数据结构上做了文章,使用常量下标对元素进行定位,而不是使用JDK默认的探测性算法。

  • ThreadLocal的实际应用

    1. 之前我们上线后发现部分用户的日期居然不对了,排查下来是SimpleDataFormat的锅,当时我们使用SimpleDataFormat的parse()方法,内部有一个Calendar对象,调用SimpleDataFormat的parse()方法会先调用Calendar.clear(),然后调用Calendar.add(),如果一个线程先调用了add()然后另一个线程又调用了clear(),这时候parse()方法解析的时间就不对了。所以当时我们使用了线程池加上ThreadLocal包装SimpleDataFormat,再调用initialValue让每个线程有一个SimpleDataFormat的副本,从而解决了线程安全的问题,也提高了性能。

    2. 我在项目中存在一个线程经常遇到横跨若干方法调用,需要传递的对象,也就是上下文(Context),它是一种状态,经常就是是用户身份、任务信息等,就会存在过渡传参的问题。使用到类似责任链模式,给每个方法增加一个context参数非常麻烦,而且有些时候,如果调用链有无法修改源码的第三方库,对象参数就传不进去了,所以我使用到了ThreadLocal去做了一下改造,这样只需要在调用前在ThreadLocal中设置参数,其他地方get一下就好了

    3. 特别在数据库连接和session管理中,这些场景涉及多个复杂对象的初始化与关闭,如果在每个线程中声明私有变量,线程变得不再轻量级,需要频繁的创建和关闭连接。

ConcurrentHashMap

为了保证存数据时的线程安全,JDK1.8之前将数组按照段进行划分并且在段上加锁(虽然是尽可能减小了锁的粒度,但是还是有些粗)。JDK1.8之后就按照槽的粒度进行加锁,具体采⽤CAS和Sychronized锁来保证线程安全(往ConcurrentHashMap中插入新的键值对时,如果对应的数组下标元素为null,那么通过CAS操作原子性地将节点设置到数组中,否则会对数组下标存储的元素(也就是链表的头节点)加synchronized锁, 然后进行插入操作)

1. ConcurrentHashMap 采用了分段锁(Segment),每个分段锁维护着几个桶(HashEntry),多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是 Segment 的个数,默认是16)。对于容器大小,每个Segment维护了一个count,需要统计每个Segment的count值并进行累加.ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。

2. JDK1.8中,ConcurrentHashMap同HashMap一样在链表的长度到达8时转化为红黑树,提升大量冲突时的查询效率。


对于put操作,如果Key对应的数组元素为null,则通过CAS操作将其设置为当前值。如果Key对应的数组元素(也即链表表头或者树的根元素)不为null,则对该元素使用synchronized关键字申请锁,然后进行操作。如果该put操作使得当前链表长度超过一定阈值,则将该链表转换为树,从而提高寻址效率。对于读操作,由于数组被volatile关键字修饰,因此不用担心数组的可见性问题。对于Key对应的数组元素的可见性,由Unsafe的getObjectVolatile方法保证。同时每个元素是一个Node实例(Java 7中每个元素是一个HashEntry),它的Key值和hash值都由final修饰,不可变更,无须关心它们被修改后的可见性问题。而其Value及对下一个元素的引用由volatile修饰,可见性也有保障。

引⼊⾼性能的乐观锁,为什么还要引⼊Synchronized这种重量级锁呢?

因为在后来Oracle公司对Sychronized锁进⾏⾥优化,即锁升级机制,单单的Synchronized锁中包含了偏向锁,轻量级锁,重量级锁三个实现。⼤⼤减少了不同情况并发下的运⾏数据。

CopyOnWriteArrayList

使用读写锁仍然会出现读线程阻塞等待的情况。CopyOnWriteArrayList基于写时复制的思想通过延迟更新策略实现数据最终一致性,思想仍然是读写分离。读数据直接读取,写操作采用ReetrantLock保证同一时刻只有一个写线程正在进行数组复制,由于数组引用被volatile修饰,根据happens-before规则,将旧的数组引用指向新的数组且写线程对数组引用的修改对读线程是可见的。

  • 存在的问题:

    1. 写操作时,内存里会同时驻扎两个对象的内存。内存占用导致频繁的minor GC和major GC
    2. 只能保证最终一致性,不能保证实时一致性

ConcurrentLinkedQueue

Blocking Queue

  • ArrayBlockingQueue

  • LinkedBlockingQueue

原子类(非互斥同步保证线程并发安全)

非互斥同步指的是不同的线程不对共享资源进行独占,不同的线程都可以访问共享资源,只不过当多个线程同时对一个共享变量进行修改或删除时,只有一个线程的操作能成功其他的都会失败

  1. CAS

CAS是JDK提供的非阻塞原子性操作,它通过硬件保证了比较——更新操作的原子性。当CAS操作失败后会进行一定的尝试(自旋),并不是进行耗时的挂起唤醒操作,因此叫做非(互斥)阻塞同步。

**ABA问题**

ABA问题的产生是因为变量的状态值产生了环形转换,一个值原来是A,变成了B又变成了A,CAS检查不出变化但值实际上被更新了两次。JDK的AtomicStampedReference类给每一个变量的状态值都配备了时间戳避免了ABA问题的产生。


Java中的原子类分为6种,分别有:

1. AtomicInteger、AtomicLong、AtomicBoolean
2. AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
3. AtomicReference、AtomicStampedReference、AtomicMarkableReference
4. AtomicIntegerFieldupdater、AtomicLongFieldupdater、AtomicReferenceFieldupdater
5. LongAdder、DoubleAdder
6. LongAccumulator、DoubleAccumulator

直接使用Java中的原子类进行操作即可在并发情况下保证变量的线程安全,原子类相较于锁粒度更小,性能更高。原子类也是基于CAS算法来实现的,其都包括compareAndSet()方法即为先比较当前值是否等于预期的值然后进行数据的修改从而保证了变量的原子性。

2. AtomicIntegerFieldUpdater

对于已有类,在不更改源代码的前提下实现对成员变量的原子操作,并且成员变量必须是volatileint基础类型。

3. AtomicIntegerArray

实现对数组中每个元素的原子操作而不是整个数组的原子操作。

1. addAndGet(int i, int delta):以原子更新的方式将数组中索引为 i 的元素与输入值相加;
2. getAndIncrement(int i):以原子更新的方式将数组中索引为 i 的元素自增加 13. compareAndSet(int i, int expect, int update):将数组中索引为 i 的位置的元素进行更新

4. LongAdder

由于AtomicLong内部是一个volatile long型变量,由多个线程对这个变量进行CAS操作。多个线程同时对一个变量进行CAS操作,在高并发的场景下仍不够快。LongAdder在每个线程操作的过程中并不会实时的进行数据同步(由于上文所提到的JMM,AtomicLong会实时的进行多个线程之间的数据通信),所以效率更高。而LongAccumulator扩展了LongAdder使得原子变量不仅只能进行累加操作也可以进行其他指定公式的计算;采用ConcurrentHashMap中分段锁的方式,将一个变量拆分成多个变量,即把一个Long型拆成一个base变量外加多个Cell,每个Cell包装了一个Long型变量,多个线程并发累加时,如果并发度低就直接加到base变量上,否则平摊到Cell上直到取值时将base与所有Cell求和。

ComputeFuture

工具类

AQS

AQS是用来构建锁和其他同步组件的基础框架,其依赖于一个int成员变量表示同步状态以及通过一个FIFO队列构成等待队列,其子类要重写AQS的几个protected修饰的用来改变同步状态的方法

同步组件通过使用AQS提供的模板方法实现同步语义,AQS实现了对比同步状态的管理,对阻塞线程进行排队和等待通知。

AQS的核心包括同步队列、独占式锁的获取与释放、共享锁的获取与释放以及可以中断锁,超时等待锁获取这些特性的实现。

同步队列:AQS中利用双向链表和CAS实现了一个阻塞队列。head指针指向双向链表头部,tail指针指向双向链表尾部,入队是将Node加到tail尾部并对tail执行CAS操作,出队对head进行CAS操作并把head指针后移,每一个Node承载一个阻塞线程。

多线程协作完成任务CountDownLatch

作用类似于join,等待指定线程执行完毕主线程才能继续执行任务。可以在每一个子线程进行countDown操作,在主线程进行await,当countDown结束时await被唤醒。调用CountDownLatch的countDown方法时,当前线程不阻塞并继续向下执行。

CyclicBarrier

类似CountDownLatch同样具有等待计数功能,多个线程都达到了指定点后,才能继续往下继续执行。CyclicBarrier经过使用之后仍然有效,可以继续作为计数器使用。

  • CountDownLatch与CyclicBarrier的异同
    1. CountDownLatch和CyclicBarrier都可视作计数器
    2. CountDownLatch强调某线程A等待若干其他线程执行完毕后才继续执行,即一个线程等多个线程;CyclicBarrier强调一组线程互相等待至某个状态然后这一组线程再继续执行,即CyclicBarrier是多个线程互相等待至达成一致状态。
    3. 调用CountDownLatch.countDown当前线程不被阻塞并继续执行;调用CyclicBarrier.await会阻塞当前线程直至所有线程达到了一致状态才继续执行。
    4. CountDownLatch不可复用,而CyclicBarrier可复用。

Semaphore

信号量Semaphore提供了资源数量的并发控制,初始化共享资源为1时,Semaphore退化为排它锁。Semaphore也有公平与非公平之分,基于AQS实现的Semaphore在strate减为0时进入阻塞状态。

调用acquire其实本质上就是通过CAS对state进行加操作,release则是通过CAS对state进行减操作。Semaphore内部存在一个继承了AQS的同步器Sync,在tryAcquireShared中尝试获取资源,如果获取失败返回负数并且当前线程进入AQS的等待队列。

Exchanger

一般用于两个线程之间更方便的在内存交换数据

Condition

Object.wait、Object.notify与对象监视器锁配合完成线程等待通知机制,而Condition与Lock配合完成线程间等待通知机制。前者java底层级别,后者语言级别并具有更高可控制性和可扩展性。

与Object.wait、Object.notify相比Condition可以支持不响应中断、通过new多个Condition对象实现多个等待队列(Object对象监视器上只能拥有一个同步队列和一个等待队列,而并发包中的Lock拥有一个同步队列和多个等待队列),支持超时时间的设置。

创建Condition对象使用lock.newCondition()创建ConditionObject对象,其作为AQS内部类。在锁机制的实现上,AQS维护了一个同步队列,对于独占锁,所有获取锁失败的线程尾插入到双向同步队列,类似AQS,Condition内部维护了一个单向等待队列,调用Condition.await()的线程释放锁以阻塞等待状态加入等待队列并唤醒同步队列的下一个节点,直至调用signal/signalAll将当前线程从等待队列移到同步队列中或者等待被中断时做出中断处理,然后自旋过程中线程不断尝试获取同步状态,直至成功(线程获取到lock),之后从await方法返回,ConditionObject通过持有等待队列的头尾指针来管理等待队列。

这里当前线程获取到同步状态等价于当前线程获取到Lock等价于当前线程是同步队列中的头结点。

LockSupport

LockSupport是线程的阻塞原语,用来阻塞线程和唤醒线程

原理:每个使用LockSupport的线程都会与一个许可证关联,如果该许可证可用并且在当前线程可用,那么调用park方法立即返回,否则会被阻塞,注意许可不可重入,即便已经获得许可再次申请许可仍然被阻塞。

  1. 线程调用LockSupport.park()致使线程阻塞;其他线程调用LockSupport.unpark(thread)唤醒指定的线程。
  2. synchronized致使线程阻塞,线程会进入到BLOCKED状态,但是调用LockSupport方法阻塞线程会使线程进入WAITING状态。

线程池

  • 使用线程池的优势

    1. 复用已存在线程,减少线程的创建和销毁次数降低了系统的性能消耗
    2. 复用已存在线程,省去了创建线程的时间开销,提升了系统的响应速度
    3. 提高了线程的可管理性,使用线程池来管理线程。Java代码中的线程与操作系统中的线程一一对应,系统可创建的线程数是有限制的,使用线程池对资源进行管理有效避免了OOM异常
  • 线程池的工作机制

    1. 线程总数量 < corePoolSize,无论池中是否有空闲线程,都会新建一个核心线程执行任务,这里需要获得全局锁。具体说来,ThreadPoolExecutor在创建线程时,会将线程封装成工作线程worker,并放入工作线程组中,worker反复从阻塞队列中拿任务去执行。
    2. 若当前线程总数量 = corePoolSize,判断阻塞队列是否已满,如果阻塞队列未满则将提交的任务存放在阻塞队列中
    3. 如果阻塞队列已满并且当前线程数量 < maxPoolSize,创建新的线程来执行任务
    4. 线程数量=maxPoolSize,执行拒绝策略
  • 使用线程池 Executor -> ExecutorService -> ThreadPoolExecutor

    Future -> FutureTask

    1. ThreadPoolExecutor接受两种类型的任务:Callable和Runnable:

      1. Callable:该类任务有返回结果,可以抛出异常。通过submit方法提交,返回Future对象。通过get获取执行结果。
      2. Runnable:该类任务只执行,无法获取返回结果,在执行过程中无法抛异常。通过execute或submit方法提交。
    2. FutureTask实现了RunnableFuture接口,同时具有Runnable无返回值执行一个线程和具有Callable返回一个Future执行一个线程的功能,直接按照装饰者模式将Callable传入构造参数即可构造。ExecutorService(ThreadPoolExecutor)可以submit一个Runnable也可以submit一个Callable也可以submit一个FutureTask

      1. submit一个Callable时,方法的返回值调用get()得到结果
      2. submit一个FutureTask时,futureTask调用get()得到结果
  • 线程池的创建

    1. corePoolSize、maxPoolSize、workQueue: 当用户向线程池提交一个新任务时,线程池首先判断当前已创建的线程数是否小于corePoolSize,如果小于这个值,则不管当前线程池中是否存在空闲线程都会创建一个新线程来执行直到等于corePoolSize;若线程池线程数等于corePoolSize,继续添加新任务会判断当前任务队列workQueue是否已满,未满放入任务队列,已满判断当前的线程数量是否超过最大线程数,如果未超过则创建一个新线程来执行任务;若当前线程数量已经超过最大线程数就执行拒绝策略。 workQueue: 阻塞队列中维护着等待执行的Runnable任务对象。阿里内部的开源规范禁止使用无解队列,避免因任务不受限制的提交导致OOM,此外使用无解队列,maxPoolSize这个参数将失效。 1.SyncbronousQueue:同步队列, 容量为0不能存储新任务, 每个put操作必须等待一个take操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene。 Executors.newCachedThreadPool() 创建一个按需创建线程的线程池,初始线程个数为0,最大线程个数为Integer.MAX_VALUE,阻塞队列为SyncbronousQueue,keepAliveTime=60表明只要当前线程在60s内空闲就被回收。执行很多短时间的任务,CacheThreadPool的线程复用率高,可以显著的提升性能。并且由于CacheThreadPool在线程没有任务进来的时候,60S回收,所以不会占用过多资源。newCachedThreadPool只会创建非核心线程。

       2.LinkedBlockingQueue:链表实现的阻塞队列(按FIFO排序),可以设置队列长度,不设置默认为无界阻塞队列,吞吐量高于ArrayBlockingQueue。
       	Executors.newFixedThreadPool()创建一个核心线程数和最大线程数都为nThreads的线程池,默认创建的是无界队列LinkedBlockingQueue,keepAliveTime=0表明只要线程数比核心线程数多并且当前空闲就回收。newFixedThreadPool只会创建核心线程。
      
       	Executors.newSingleThreadExecutor()创建一个核心线程数和最大线程数都为1的线程池,默认创建的是无界队列LinkedBlockingQueue,keepAliveTime=0表明只要线程数比核心线程数多并且当前空闲就回收。不会创建非核心线程,任务按照先来先执行的顺序执行。
       3.ArrayBlockingQueue:数组实现的有界阻塞队列(按FIFO排序),可以设置队列长度,此时maxPoolSize有效
       4.DelayQueue:延迟队列,队列中的元素只有当指定的延迟时间到了才能从队列中获取到元素
       	Executors.newScheduledThreadPool创建一个ScheduledThreadPoolExecutor,其为可以指定一定延迟时间或者定时任务调度的线程池,默认使用DelayedWorkQueue
       5.PriorityBlockingQueue:是具有优先级的无界阻塞队列
      
    2. keepAliveTime:当多余线程的空闲时间超过keepAliveTime时,它们将被回收。unit是keepAliveTime的时间单位。如果将allowCoreThreadTimeOut设置为true,那么核心线程也会因空闲而关闭。

    3. ThreadFactory:线程池中新创建的线程是由ThreadFactory创建的,默认使用Executors.defaultThreadFactory()。进行自定义线程工厂可以为线程池中的线程命名,方便使用jstack命令查看线程栈时快速识别对应的线程。

    4. handler:拒绝策略,触发条件:线程池使用的是有界任务队列时,才有可能被触发,当队列已满,并且线程池创建的线程已经达到了最大允许的线程池时。

      1. AbortPolicy,默认值,丢弃任务并抛出RejectedExecutionException异常。默认情况下,采用AbortPolicy即可。补偿措施一般可以考虑在捕获到异常后,采用重试,特别的可以结合定时任务进行定时重试。
      2. CallerRunsPolicy,由调用线程处理该任务。 CallerRunsPolicy异步转同步的在出现拒绝的情况下意义不大,没有什么合适的场景,当执行拒绝策略的时候说明CPU处理已经变慢,此时再同步执行任务只会增加CPU的负担,不利于恢复问题。
      3. DiscardOldestPolicy,丢弃队列头部任务并重新尝试执行程序。DiscardOldestPolicy类似记录轨迹,用于希望保存最新人物并且对任务丢弃有容忍的情况下。
      4. DiscardPolicy,丢弃任务但不抛出异常。DiscardPolicy通常用于异步打印日志,希望保留旧数据且忽略新数据。
  • 线程池中的锁: 用户线程提交到线程池之后由Worker来执行。mainLock独占锁,用来控制新增Worker线程操作的原子性(多个线程同时在添加线程,那么workers队列的内容要保持一致性),termination是锁对应的条件队列,在线程调用awaitTermination时用来存放阻塞的线程。Worker继承AQS和Runnable接口,是具体承载任务的对象,Worker继承了AQS,自己实现了简单不可重入独占锁。

  • 线程池中的状态:

    线程池创建后处于RUNNING状态;调用shutdown()方法后处于SHUTDOWN状态,线程池不能接收新的任务并清除一些空闲worker,不会等待阻塞队列的任务完成;调用shudownNow()方法后处于STOP状态,线程池不能接收新的任务,中断所欲线程,阻塞队列没有执行的任务全部丢弃,poolsize=0,阻塞队列的size=0;所有任务已经终止,ctl记录的任务数量为0,线程池处于TIDYING状态,执行terminated方法转入TERMINATED状态

生产者消费者模型

三种实现方式:

  1. Object的消息通知机制,Object.wait、Object.notify

  2. Lock的Condition的消息通知机制,await、signal

  3. BlockingQueue

Object.wait、Object.notify

  1. 线程调用Object.wait会阻塞当前线程,直至其他线程调用Object.notify、Object.notifyAll之后,当前线程才从wait方法返回

  2. 调用wait方法之前,线程需要获得对象的监视器锁,成功获得后,在同步区内调用wait方法,当前线程会释放锁并且进入休眠状态(WAITING状态)直到通知或中断为止。

  3. 调用notify方法之前,线程需要获得对象的监视器锁,此方法从处于WAITING状态的线程挑选一个进行通知,使调用过wait方法的线程从等待队列进入同步队列从而等待进一步获取锁的机会,调用notify方法之后,当前线程不会立刻释放对象锁而在等到程序退出程序块之后当前线程才释放锁。

wait/notify潜在的一些问题

  1. notify通知过早,通过添加状态标志,让waitThread调用wait之前检查状态是否发生改变,如果notify过早发出,waitThread不再wait。

    在使用线程的等待、通知机制时,一般要配合一个boolean变量,在notify之前改变boolean变量值,而在wait方法逻辑外围加一层while循环,条件是boolean变量,以防过早通知。最终使得wait返回后能够退出while循环,即便出现通知遗漏,线程也不会永久阻塞在wait处,保证程序正确性。

  2. 多个线程根据一定条件执行wait,可能在wait释放环节条件发生改变从而导致错误。解决方法仍然是while包裹wait,只是条件注意确定好。

  3. 多生产者多消费者使用notify出现“假死”现象,即唤醒同类线程,表现为多个生产者调用wait阻塞等待,生产者线程获取到锁后使用notify通知处于WAITTING状态的线程,但是如果唤醒的仍然是生产者线程就导致所有生产者线程都处于等待状态。解决方法:notify替换为notifyAll,signal替换为signalAll

使用Lock中Condition的await和signalAll实现生产者和消费者

使用BlockingQueue最简易实现生产者和消费者模型