Java 多线程

摘要:Java高级之多线程、并发编程,包括线程的基本概念、生命周期、线程调度通信阻塞,创建线程的方式、线程池,JMM(volatile)、线程安全(悲观锁、CAS)、线程同步(synchronized ),并发容器(AQS 、JUC 工具类)。

目录

[TOC]

  1. 线程的基本概念、生命周期、线程调度通信阻塞,
  2. 创建线程的方式、线程池,
  3. JMM(volatile)、线程安全(悲观锁、CAS)、线程同步(synchronized ),并发容器(AQS 、JUC 工具类)。

推荐书

  • 《深入理解Java虚拟机》

  • 《Java并发编程的艺术》

基本概念

串行、并行、并发

  1. 串行(xing):多个任务由同一线程按顺序执行;时间上不重叠。
    • e.g. 一个队列和一台机器。
  2. 并行:多个任务(在多核CPU上,由不同线程)在同一时刻执行;(逻辑上)互不干扰。
    • e.g. 两个队列和两台机器。目前发展方向。
  3. 并发:多个任务(在同一CPU上,)在同一时间段内(按时间片)轮流交替执行,逻辑上是同时执行;允许两个任务彼此干扰。
    • e.g. 两个队列和一台机器。多核 CPU 之下,并发往往意味着并行.

同步、异步

  • 同步:必须等被调用的方法结束后,后面的代码才能执行。
  • 发出一个调用后,在没有得到结果前, 该调用就不可返回,一直等待。
  • 异步:不管被调用方法是否完成,都会继续执行后面的代码,调用完成后会通知调用者。
    • 调用在发出后,不用等待返回结果,该调用直接返回。

简单来理解就是:同步按代码顺序执行,异步不按照代码顺序执行,异步的执行效率更高。

线程同步:两个或多个共享关键资源的线程并发执行。用于避免关键资源使用冲突。

  • 异步代码题,说出打印顺序

多线程

多线程:多个线程并发执行。一个进程运行时产生了多个线程。

优点:

  1. 计算机底层:

    • 对单核CPU:减少CPU的空闲时间,提高CPU利用率;防止阻塞;但可能导致频繁的上下文切换;
    • 对多核CPU:充分利用多核CPU的计算能力;减少线程上下文切换的开销;
  2. 方便进行业务拆分和建模。

带来的问题:

  1. 线程越多占用内存也越大 -> 内存泄漏;
  2. 竞争共享资源 -> 线程不安全、死锁;
  3. 单核 CPU 中,频繁的上下文切换;

上下文切换

上下文切换:指CPU控制权(由一个正在运行的线程)切换到另一个(就绪并等待获取CPU执行权的)线程的过程。

作用:保存当前线程的上下文,用于再次占用 CPU 时恢复现场,并加载下一个将要占用 CPU 的线程的上下文。

原因:

  1. running:时间片用完,因为 OS 要防止一个线程长时间占用 CPU 导致其他线程饿死;
  2. blocked:调用了阻塞类型的系统中断,如请求 IO、线程被阻塞;
  3. waiting:主动让出 CPU,如调用了 wait()、sleep() 等;
  4. terminted:被终止或结束运行。

减少上下文切换可采用:

  1. 无锁并发编程;
  2. 用 Atomic 类 CAS 算法更新数据,乐观锁可有效减少不必要的锁竞争带来的上下文切换;
  3. 使用最少线程:避免创建不需要的线程,(如任务很少却创建了很多线程,会造成大量线程处于等待状态);
  4. 协程:在单线程里实现多任务的调度,并维持多个任务间的切换。

生命周期的六种状态

java.lang.Thread.State 枚举类中定义了六种线程的状态,可以调用线程 Thread 中的 getState() 方法获取当前线程的状态。

Java 线程状态变迁图

  1. new 新建:新建线程对象(仅由 JVM 为其分配内存,并初始化成员变量的值)。
    • 在调用 Thread.start() 启动线程后进入ready 就绪状态;不允许对一个线程多次使用 start() 方法;
  2. runnable 可运行(线程池):
    • ready 就绪:等待被线程调度选中、等待获取 CPU 使用权;(JVM 为线程创建 PC 程序计数器、VM 栈、本地方法调用栈);
      • 唯一入口:通过 join() 获得 CPU 执行权、CPU 资源后进入 running;
    • running 运行:除非此线程主动放弃 CPU 资源(时间片用完通过 yield() 进入 ready 就绪)或有更高优先级的线程进入(通过wait() 进入 blocked 阻塞),将一直运行、直到结束。
      • join() 的线程执行完毕后进入 ready 就绪状态。
      • 时间片:指将可用的 CPU 时间分配给线程,可基于优先级或等待时间;
    • 对于单核处理器,一个时刻只能有一个线程处于运行状态。
  3. blocked 阻塞(图中上方的):线程因某种原因(如发出阻塞式I/O请求、通过 sleep())让出 CPU,暂时停止运行、等待获取锁;
    • 解除阻塞、获取到锁(如阻塞式IO处理完毕、操作结果返回等)后(sleep 时间到、interrupt() 中断)重新进入ready 就绪状态;
  4. waiting 等待阻塞(图中下方的):(线程内)执行 wait()、Thread.join() 后进入此状态:JVM 会把该线程放入等待池,释放占用的所有资源(包括持有的锁);
    • 不会去竞争同步锁,不能自动唤醒(需被其他线程显式唤醒),收到其他线程发出的通知后才会去竞争锁。
    • 当前线程需等待其它线程 notify()/notifyAll() 通知或中断(如某项资源就绪/另一个线程放弃排他锁)、join() 的线程执行完毕后进入 ready 就绪状态。
  5. timed_waiting 超时等待:不分配CPU,超时自动唤醒。在一定时间后结束等待(进入 ready 就绪状态)。
    • 线程调用 wait(long)、Thread.join(long)、Thread.sleep(long) (传入等待时间的参数后)进入此状态。
  6. terminted 终止、死亡:run() 线程执行完或因异常调用 stop()退出 run() 方法。

Java 线程状态变迁图

线程调度、通信、阻塞

线程调度

  • 抢占式调度模型:指优先让(可运行池中)优先级高的线程占用CPU,(处于运行状态的线程会)一直运行,直到不得不放弃 CPU。
  • 分时调度模型:
线程优先级

线程的优先级级别由操作系统决定,不同的操作系统级别是不一样的。在Java中,提供了一个级别范围1~10,方便我们去参考。

  • Java默认的线程优先级为5,线程的执行顺序由调度程序来决定,线程的优先级会在线程被调用之前设定。
  • 每个Java程序都有一个默认的主线程,就是通过JVM启动的第一个线程main线程。
  • 守护线程:为所有用户线程提供服务的线程。如:GC 垃圾回收线程,优先级比较低。如果所有的非守护线程都结束了,这个守护线程也会自动结束。
1
2
thread.setDaemon(true);
thread.start();

线程通信的方式

  1. volatile 修饰变量:保证所有线程对变量访问的可见性。主要通过读写共享变量来完成隐式通信。
  2. synchronized:多个线程时,确保在同一时刻只能有一个线程处于方法或同步块中。
  3. wait()/notify() 方法。
  4. IO 通信。

线程阻塞

线程阻塞:线程在运行的过程中因某些原因暂时放弃CPU的使用,停止运行,只有等到导致阻塞的原因消除之后才恢复运行;

  • 或是被其他的线程中断,也会退出阻塞状态,同时抛出 InterruptedException。

线程阻塞的类型、及导致阻塞的原因:

  1. 同步阻塞:获取 synchronized 同步锁时,锁被其它线程占用(获取失败),会放入锁池;
    • 其他线程释放同步锁后、锁池中的线程会去竞争同步锁,某个线程竞争成功后会解除阻塞,进入 ready。
    • 等待另一个线程的 synchronized 块释放锁,或可重入的 synchronized 块里别人调用 wait(),即线程在等待进入临界区。
  2. waiting 等待阻塞:

  3. timed_waiting 超时等待:
唤醒/解除阻塞

唤醒:让线程重新进入就绪状态。

进入阻塞,及对应的唤醒/解除/结束阻塞、恢复线程的时机:

  1. blocked 阻塞:发出I/O请求导致的IO阻塞;无法唤醒,只能等到I/O处理完毕才能解除阻塞。Thread.join()、Thread.sleep(long)。
  2. waiting 等待:调用 wait()、join()导致的阻塞;join()等待线程终止或超时后唤醒。
  3. timed_waiting 超时等待:调用 wait()、join()、sleep() 让出占用的CPU资源导致的阻塞;sleep()超时。可中断线程并抛出 InterruptedException。
  4. 调用 yield() 让出占用的CPU资源(给优先级相同或更高的线程)、进入ready。
  5. 调用 suspend() 挂起线程(被挂起后不会释放锁,可能与其他线程、主线程产生死锁,已废弃);resume() 恢复线程。

Java 线程状态变迁图

常用方法
  1. Object.wait():使一个线程进入 waiting等待阻塞状态,并释放所持有的(对象的)锁,用于线程交互;
  2. Thread.join():等待调用join()方法的线程执行结束,程序才会继续执行下去。只有在start()方法之后才可生效。进入 blocked阻塞?
  3. Thread.sleep(long):使线程进入 blocked阻塞?;一直持有锁,用于暂停执行;静态方法,要处理 InterruptedException 异常;
  4. Thread.yield():暂停当前正在执行的线程对象,让给其他线程(包括它自己)执行。在多线程的情况下,具体由CPU决定执行哪一个线程。使当前线程从 running(不能被其他状态调用)直接变为 ready,让出CPU时间片;静态方法。
  5. Object.notify():唤醒一个 waiting 状态的线程;由 JVM 决定唤醒哪个线程,与优先级无关;
  6. Object.notityAll():唤醒所有 waiting 状态的线程;所有线程竞争锁,竞争成功、获得锁的线程进入就绪状态;
  7. LockSupport.unpark(Thread):

static Thread Thread.currentThread():返回当前线程。在 Thread 子类中就是this,常用于主线程和 Runnable 实现类。

boolean isAlive():判断线程是否还活着。

Java 线程状态变迁图

sleep()、wait()

共同点 :二者都可以暂停线程的执行。

区别 :

  1. 用途:wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁,并让其进入 waiting 等待阻塞状态。sleep() 常用于当前线程休眠、或轮询暂停执行操作,让线程进入 blocking 阻塞状态。
    • wait() 常用于线程间交互/通信
    • wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁
    • 每个对象都拥有对象锁,要操作对应的对象(Object)而非当前的线程(Thread);因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需获得对象锁。
  2. 所属类:wait() 是 Object 类的普通本地方法;sleep() 是 Thread 类的静态本地方法。
  3. 释放锁(最大不同):wait() 会释放锁,加入等待池,可能还有机会重新竞争到锁继续执行;sleep() 一直持有锁,即使有synchronized 同步块,其他线程仍不能访问共享数据,会让出 CPU 执行时间且强制上下文切换。
  4. 唤醒:wait() 需其它线程调用 notify()/notifyAll() 唤醒,或 timed_waiting 超时;sleep() 执行完自动唤醒,也可用 wait(long timeout) 超时后线程会自动唤醒。
  5. 依赖:线程调用对象的 wait()/notify()/notifyAll() 时,都必须先获得对象的锁==> 所以只能在同步方法/同步块中被调用。wait() 依赖同步器 synchronized,而sleep()不依赖。

同步块比同步方法更好:同步块外的代码是异步执行的,比同步整个方法效率更高。

原则:同步的范围越小越好。

sleep()、yield()
  1. 优先级:sleep() 不考虑线程的优先级,会给低优先级的线程运行的机会;yield()只会给相同或更高优先级的线程机会;
  2. 作用:执行 sleep() 后转入blocked,而执行 yield() 后转入ready;
  3. 异常:sleep() 声明抛出 InterruptedException,而 yield() 没有声明任何异常;
  4. 移植性:sleep() 比 yield()(跟OS CPU 调度相关)更可移植,不建议用 yield()来控制并发线程。

创建线程

创建线程的三/五种方式

  1. 继承 Thread 类,重写 run(),调用 start() 启动线程。缺点:单继承
  2. 实现 Runnable 接口,重写 run(),并将创建的子类对象(作为参数)传给 Thread 类的构造器 new Thread(Runnable obj) 来创建线程对象,调用 start() 启动线程。
  3. 实现 Callable 接口,重写 call(),并将创建的子类对象(作为参数)传给 Thread 类的构造器来创建线程对象,调用 start() 开启线程。
  4. 实现 ExecutorService 接口创建线程池;
  5. 使用线程工厂 ThreadFactory 创建线程池;
  6. (用两种方式)创建线程池(都实现了 ExecutorService 接口):用时直接获取,用完放回。

Runnable、Callable 接口区别

  1. Runnable 接口通过重写 run() 创建对象,Callable 接口通过重写 call();
  2. Runnable 接口没有返回值,Callable 接口有泛型返回值,和 Future、FutureTask 配合可用来获取异步执行的结果:创建 FutureTask(Callable cal) 对象,传给 Thread 的构造器,提交给 ExecutorsService 执行,放入线程池中;
  3. Runnable 接口 run() 只能抛出运行时异常,且无法捕获处理;,Callable 接口 call() 允许抛出异常,可获取异常信息;
  4. Runnable 接口可继承其他类,多实现;可用匿名内部类简化。

start() VS run()

  • start():用于(启动线程)创建新线程,并自动执行在 run() 里的代码,使线程进入 ready 状态;只能调用一次;

  • run():用于执行线程的运行时代码;可重复调用;只是 Thread 的一个普通方法,仍在主线程内执行;不会创建新线程也不会调用线程的代码。

多次start一个线程会怎么样?

启动多个线程,多线程。

多线程执行时要用 start() 而不是直接调用 run():

  1. 调用 start() 方法方可启动线程并使线程进入就绪状态;
  2. 直接执行 run() 方法,不会以多线程的方式执行;会当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行;

线程类的构造方法、静态块是被 new 本线程所在的线程所调用的,而 run() 方法内的代码才是被线程自身所调用的。

终止线程运行

  1. run()/call() 执行完,线程正常结束;
  2. 用 interrupt() 中断线程,抛出 interruptedException;
  3. 直接调用 stop() 强行终止;stop/suspend/resume 都是过期作废的方法,容易导致死锁,故不推荐。

线程池

池化技术:为了减少每次获取资源的消耗,提高利用率。如线程池、数据库连接池、Http 连接池等。

好处
  1. 降低资源消耗。重复利用已创建的线程,减少线程创建/销毁、及系统资源的开销。
  2. 提高响应速度。当任务到达时,不需等到线程创建就能立即执行。
  3. 提高线程的可管理性。线程是稀缺资源,无限制的创建会消耗系统资源、降低系统的稳定性(OOM),可统一分配、调优和监控。
实现原理

图解线程池实现原理

提交一个新任务到线程池时:

  1. 若核心线程池未满/其中的线程不全在执行 ==> 创建新线程执行任务;
  2. 若核心线程池已满,任务(等待)队列未满 ==> 放入任务队列等待执行;
  3. 若任务队列已满,判断线程数 < 线程池最大线程数(线程池未满)==> 创建临时线程处理任务;
  4. 线程数 > 最大线程数 ==> 根据拒绝策略处理任务。

Executor 框架

Executor 框架结构
  1. 任务:Runnable /Callable 接口;
  2. 任务的执行:任务执行机制的核心接口 Executor 及继承它的 ExecutorService 子接口(真正的线程池接口,用于创建并返回不同类型的线程池):
    • 两个关键(线程池)实现类:ThreadPoolExecutor 和 ScheduledThreadPoolExecutor;
  3. 异步计算的结果:Future 接口及其实现类 FutureTask 类;

用 Executor 框架执行线程
  1. 主线程首先要创建实现 Runnable 或 Callable 接口的任务对象;
  2. 创建 ExecutorService线程池;
  3. 把创建的对象交给 ExecutorService 线程池执行:
    1. 直接执行:ExecutorService.execute(Runnable command)):没有返回值;用于提交不需返回值的任务,无法判断任务是否被线程池执行成功与否;
    2. 提交执行:ExecutorService.submit(Runnable task/Callable <T> task):用于提交需要返回值的任务。和 execute()方法的区别是,会返回一个实现 Future 接口的 FutureTask 对象,用于判断任务是否执行成功。
      • 可通过 Future 的 get()方法来获取返回值,会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时有可能任务没有执行完。
  4. 由于 FutureTask 实现了 Runnable,也可创建 FutureTask,然后直接交给 ExecutorService 执行。
  5. 最后,主线程可执行 FutureTask.get()方法来等待任务执行完成,也可执行 FutureTask.cancel(boolean mayInterruptIfRunning)来取消任务执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 用构造器创建线程池
ExecutorService service = new ThreadPoolExecutor(5, 10, 10, TimeUnit.SECONDS, new LinkedBlockingQueue<>());

// 1.创建一个 Runnable 接口的(匿名)实现类,重写 run(),并提交给 execute()
service.execute(new Runnable() {
	@Override
	public void run() {
	    System.out.println("execute方式");
	}
});

// 2.创建一个 Callable 接口的(匿名)实现类,重写 call(),并提交给 submit()
Future<Integer> future = service.submit(new Callable<Integer>() {
	@Override
	public Integer call() throws Exception {
		System.out.println("submit方式");
		return 2;
	}
});

try {
	Integer number = future.get();
} catch (ExecutionException e) {
	e.printStackTrace();
}

创建线程池

3、5种创建线程的方式

创建线程池的方式:

  1. 通过(ExecutorService 接口的实现类)ThreadPoolExecutor(的五种构造器)创建线程池;
  2. 通过五种 Executors 静态工厂类。使用线程工厂 ThreadFactory 创建线程池;
ThreadPoolExecutor()

Alibaba 推荐,更明确线程池的运行规则,规避资源耗尽的风险。

ThreadPoolExecutor构造方法

构造器 ThreadPoolExecutor() 的四个参数有:

  1. int corePoolSize:核心线程数,线程池中活跃的线程数;最小可同时运行的线程数量;
  2. int maxinumPoolSize:允许同时运行的最大线程数;当队列中存放的任务达到队列容量时,可同时运行的线程数量变为最大线程数;
  3. long keepAliveTime:空闲线程(超出核心线程数外的)的最大存活时间;
  4. TimeUnit unit:空闲时间的单位;
  5. BlockingQueue<runnable> workQueue:任务队列,存放等待执行的任务; 若当前运行的线程数量达到核心线程数,新任务会被存放在队列中。

其中前5个为共有,可选参数有:

  1. [ThreadFactory threadFactory]:用于 executor 创建新线程;
  2. [RejectExecutionHandler handler]:任务拒绝策略。
任务拒绝策略

饱和策略

如果当前同时运行的线程数量达到最大线程数量且队列也已经被放满了任务时,ThreadPoolTaskExecutor 定义一些策略:(用 ThreadPoolExecutor中的枚举)

  1. .AbortPolicy: 默认策略,抛出 RejectedExecutionException来拒绝新任务的处理。
  2. .CallerRunsPolicy: 调用执行自己的线程运行任务,即直接在调用execute方法的线程中运行(run) 被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。
    • 会降低对于新任务提交速度,影响程序的整体性能。用于可承受此延迟、且要求任何一个任务请求都要被执行的情况。
  3. .DiscardPolicy: 不处理新任务,直接丢弃。
  4. .DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。
Executors 静态工厂类

通过 Executor 框架的工具类/线程池的静态工厂类 Executors 来实现。

目的是将任务提交和运行分离,用户不需从代码层考虑设计任务的提交运行。不推荐。

Executor框架的工具类

常见线程池及创建接口
  1. newSingleThreadExecutor():创建只有一个线程的线程池。若多个任务被提交到线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  2. newFixedThreadPool(n):创建(可重用)固定线程数的线程池;不推荐。线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行;若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时再处理。
  3. newCachedThreadPool():创建可按需创建新线程的线程池。线程池的线程数量不确定,但若有空闲线程可复用,则会优先使用可复用的线程;若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
  4. newScheduledThreadPool(corePoolSize):创建可定时执行的线程池,指定核心线程数。

  5. newWorkStealingPool():创建有多个任务队列的线程池。

注意:

  • 1、2:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM;
  • 3、4:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM;

确定线程池大小

线程池中的线程数量

  • 太小,(如果同一时间有大量任务/请求需处理,)可能会导致大量的请求/任务堆积在任务队列中(排队)等待执行,甚至会出现任务队列满导致 OOM;CPU 没有得到充分利用。

  • 太大,可能会同时争取 CPU 资源,导致频繁的上下文切换,增加线程的执行时间,影响整体执行效率。

有一个简单且适用面较广的公式(N 为 CPU 核心数):

  1. CPU 密集型任务(N+1): 利用 CPU 计算能力的任务,消耗的主要是 CPU 资源,如在内存中对大量数据进行排序;
    • 线程偶发的缺页中断,或其它原因一旦导致任务暂停,CPU 就会处于空闲状态,(比 CPU 核心数)多出来的一个线程就可充分利用 CPU 的空闲时间。
  2. I/O 密集型任务(2N): 特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。如,但凡涉及到网络读取,文件读取这类。
    • 系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU,这时就可将 CPU 交出给其它线程使用,可多配置一些线程。

JMM(Java 内存模型)

JMM(Java 内存模型):定义了共享内存中多线程程序读写操作的行为规范,通过happens-before 原则来规范对内存的读写操作从而保证指令的正确性(解决CPU 多级缓存和指令重排问题)。

  • JMM抽象了线程和主内存间的关系。
  • 共享内存:我们定义的成员变量,创建的对象或者是数组。

JMM 把内存分为两块:

  1. 所有线程共享的主内存:线程间的共享变量(所有线程创建的实例对象)都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)。
  2. 每个线程私有的工作内存(本地内存):每个线程都有一个私有的工作内存,存储(主内存中)共享变量的副本,无法访问其他线程的本地内存;对变量的所有操作都必须在工作内存进行,不能直接读写主内存数据。

线程与线程之间是相互隔离的,线程与线程交互需要通过主内存。

线程间主要通过读写共享变量来完成隐式通信。

例如,线程 1 与线程 2 间进行通信的 2 个步骤:

  1. 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中;
  2. 线程 2 到主存中读取对应的共享变量的值。

也就是说,JMM 为共享变量提供了可见性的保障。

JMM(Java 内存模型)

Java 内存区域和 JMM 的区别:

  • Java 内存区域:和 JVM 的内存分配机制、运行时数据区相关,定义了 JVM 在运行时如何分区存储程序数据。
  • Java 内存模型(JMM):和 Java 的并发编程相关,抽象了线程和主内存之间的关系;规定了从 Java 源代码到 CPU 可执行指令的转化过程要遵守哪些(和并发相关的)原则和规范,主要目的是为了简化多线程编程,增强程序可移植性。

happens-before 规则

img

先行发生原则:定义了操作 A 必然先行发生于 B 的一些规则。

  • 符合规则,不需额外做同步措施;
  • 不符合规则,一定是非线程安全的。

happens-before 八大原则:

  1. 程序顺序规则:一个线程内,按照代码顺序,写在前面的操作 happens-before(先行发生)于后面的;
  2. 锁定规则:对同一个锁的,unlock 操作 happens-before 于 lock 操作;
  3. volatile 变量规则:对同一 volatile 变量的,写操作 happens-before 于读操作;
  4. 传递性规则:如果操作 A 先行发生于B,B 先行发生于C,则 A happens-before 于 C;
  5. 线程启动规则:线程的 start() happens-before 于此线程的每个动作;
  6. 线程中断规则:对线程 interrupt() 的调用 happens-before 于被中断线程的代码检测到中断事件的发生;
  7. 线程终止规则:线程中所有操作 happens-before 于对线程的终止检测;
  8. 对象终结规则:对象的初始化 happens-before 于 finalize() 方法;
与 as-if-serial 的区别

as-if-serial:编译器等会对原始程序进行指令重排序和优化,但和原结果一致。

  1. 语义:保证单线程和正确同步的多线程 程序的执行结果不变。
  2. 对程序员:分别按程序的顺序和happens-before指定的顺序执行。
  3. 目的:都是为(在不改变程序执行结果的前提下,)尽可能提高执行的并行度。

volatile

volatile 修饰共享变量:保证每个线程都能获取主存中的最新值,避免出现数据脏读。

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  1. 保证变量对所有线程的可见性:一个线程修改了变量值,立即更新到主存,对其他线程立即可见。其他线程直接从主存读取新值,而不是工作内存中的副本;
  2. 禁止指令重排序,保证有序性。
  3. 不能单独保证原子性,结合 CAS 可保证。
指令重排序

简单来说就是系统在执行代码时并不一定是按照代码顺序依次执行。

为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。

常见的指令重排序有下面 2 种情况:

  1. 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  2. 处理器指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可改变语句对应机器指令的执行顺序。指令重排序可保证串行语义一致,但不保证多线程间的语义也一致 ,可能会导致一些问题。
volatile VS synchronized

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

区别:

  1. 作用:volatile 用于解决变量在多个线程间的可见性(线程通信); synchronized 用于解决多个线程间访问资源的同步性(线程同步)。
  2. 性质:volatile 能保证数据的可见性(和有序性),但不能保证原子性;synchronized 两者都能保证。
  3. 修饰:volatile 只能用于变量而 synchronized 可修饰类、方法、变量及代码块;
  4. 性能:volatile 是线程同步的轻量级实现,性能比 synchronized 好;
  5. 阻塞:volatile 不会造成线程阻塞;synchronized 可能造成 。
  6. 优化:volatile 变量不会被编译器优化;synchronized变量可被优化。

ThreadLocal

ThreadLocal(线程共享变量):为每个线程维护一个本地 Map,key 为 ThreadLocal 对象,value 为对应线程的变量副本(Entry 对象)

  • ==>用于查找;以空间换时间。

用途:

  1. (主要)用于线程间的数据隔离,解决线程安全问题;填充的数据只属于当前线程;
  2. 对象跨层传递时可避免多次传递,打破层次约束;
  3. 进行事务操作,用于存储线程事物信息;
  4. 数据库连接,Session会话管理。

img

内存泄漏:由于 ThreadLocal 是弱引用,Entry 的 value 是强引用,因此当 ThreadLocal 被回收后,value 依旧不会被释放,产生内存泄漏。

避免:

  • 用完 ThreadLocal 后调用 remove() 手动清空;必须回收自定义的 ThreadLocal 变量记录的当前线程的值,尤其在线程池场景下,线程经常会被复用。尽量用 try-finally 块进行回收。
  • 用 private static ThreadLocal 修饰变量,任何时候都能访问 value,进而清除掉。
    • 是针对一个线程内所有操作共享的,故设置为 static,所有此类实例共享此静态变量。
    • 即,类第一次被使用时装载,只分配一块存储空间,所有(本线程内定义的)此类的对象都可操控这个变量。
1
2
3
4
/**
     * 订单编号
     */
private static final ThreadLocal<Long> ORDER_ID = new ThreadLocal<>(); 

线程安全

线程安全:多线程下(重复)运行的结果与单线程的结果始终一致。且其他变量的值也和预期的值始终一致。

  • 指多线程环境下,程序能够正确、稳定地运行,不会出现数据不一致或者其他不可预料的结果。
  • 堆是所有线程共享,栈是线程安全的。
  • 线程安全是目的,线程同步是实现线程安全的方法。

线程安全级别

  1. 不可变(Immutable):
    • 对象一旦被创建,任何一个线程都改变不了它的状态(数据/属性值),除非新创建一个;
    • 所有域都是 final 类型,且被正确创建(创建期间没有 this 引用的溢出)
    • ==>(保证了对象的内存可见性,)不需任何同步手段就可直接在多线程下使用。
      • 如 String、包装类、BigInteger 和 BigDecimal 等。
  2. 绝对线程安全:不管运行时环境如何,调用者都不需额外的同步措施。如
  3. 相对线程安全,即通常所说的线程安全:如 Vector 的 add()、remove() 都是原子操作,但如果多个线程同时执行遍历和add() ,会出现ConcurrentModificationException(即 fail-fast 机制)。

  4. 线程非安全:ArrayList、LinkedList、HashMap等。

线程安全策略/三要素

AVO

保证三要素、即保证线程安全:

  1. 原子性(‘Atomicity):一个(或多个操作)是不可中断的,要么全都执行,要么全都不执行。实现方式:
    1. synchronized 同步方法 / 代码块:对线程加独占锁,修饰的类/方法/变量/代码片段同一时间只允许一个线程访问;
    2. JUC 里的 Lock(手动)锁:保证同时只有一个线程能拿到锁,并执行申请锁和释放锁间的代码;
    3. 用安全类,如 Atomic 原子类(CAS)、Unsafe 类;如 i++ 是非线程安全的,可用 AtomicInteger.incrementAndGet() 替换。
  2. 可见性(Visibility):一个线程修改共享变量后,对另一个线程可见(其他线程能立即看到最新值)。
    1. synchronized:在释放锁前将工作内存新值更新到主存中。
    2. Lock;
    3. volatile:指示 JVM 这个变量是共享且不稳定的;保证每次使用共享变量前都从主内存重新读取,修改后的新值立即同步到主内存;
  3. 有序性(Ordering):对于改变顺序不影响语义的代码,JVM和CPU处理器编译时可能会优化代码和指令重排;不保证程序中各个语句的执行先后顺序同代码中一致,但它保证程序最终执行结果和代码顺序执行的结果是一致的。
    1. volatile:有禁止指令重排序的语义;
    2. synchronized:变量在同一个时刻只允许一个线程对其进行 lock 操作,使有同一个锁的两同步块只能串行地进入;
    3. Happens-Before 规则:两个操作的执行顺序只要可通过 happens-before 推导出来,则 JVM 会保证其顺序性,否则可任意重排序以提高效率。

实现线程安全的方法

线程安全工具,为了解决线程安全性问题,可以采用以下策略。

实现线程安全的主要方法包括:

  1. 避免共享数据(volatile 修饰的):尽量让每个线程只操作自己的数据,避免多线程之间共享数据,通过线程间消息传递等方式实现通信。
    • 原子操作:使用原子操作来保证多线程对共享变量的原子性操作,避免数据竞争。
  2. 使用同步机制:当必须共享数据时,使用同步机制来确保数据访问的原子性和顺序性。来保证多线程对共享资源的互斥访问。线程间的同步方式、三种并发加锁方式有:
    1. 互斥量(Mutex):采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。如
      1. synchronized 关键字(内置锁)
      2. 各种 Lock(互斥锁、ReadWriteLock 读写锁、记录锁、自旋锁)。
    2. JUC 同步/并发工具类、核心类 AQS 常用的 AQS 组件/同步器
      1. ReentrantLock 可重入锁
      2. 信号量(Semphares))、阻塞队列、闭锁、栅栏、FutureTask
    3. 事件(Event)):Wait()/Notify() 通过通知的方式来保持多线程同步,方便比较多线程优先级。
  3. 使用线程安全的集合、并发容器类:CopyOnWriteArrayList、ConcurrentLinkedQueue、BlockingQueue、ConcurrentHashMap、ConcurrentSkipListMap 、Collections.synchorizedList同步容器。见 Java 集合框架。
  4. 合理设计资源获取和释放:避免死锁情况的发生,考虑资源分配的顺序、避免循环等待等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
     * user 与 WebSocketSession 映射
     *
     * key1:用户类型
     * key2:用户编号
     */
private final ConcurrentMap<Integer, ConcurrentMap<Long, CopyOnWriteArrayList<WebSocketSession>>> userSessions = new ConcurrentHashMap<>();

@Override
public Collection<WebSocketSession> getSessionList(Integer userType, Long userId) {
    ConcurrentMap<Long, CopyOnWriteArrayList<WebSocketSession>> userSessionsMap = userSessions.get(userType);
    if (CollUtil.isEmpty(userSessionsMap)) {
        return new ArrayList<>();
    }
    CopyOnWriteArrayList<WebSocketSession> sessions = userSessionsMap.get(userId);
    return CollUtil.isNotEmpty(sessions) ? new ArrayList<>(sessions) : new ArrayList<>();
}

private final Map<ExpressClientEnum, ExpressClient> clientMap = new ConcurrentHashMap<>(8);
同步容器

Collections.synchorizedXXX;

  • ArrayList:是非线程安全的,在多线程的情况下,向list插入数据时,可能会造成数据丢失的情况。并且一个线程在遍历List,另一个线程修改List,会报ConcurrentModificationException(并发修改异常)错误。

  • Vector:是一个线程安全的List,但是它的线程安全实现方式是对所有操作都加上了synchronized关键字,这种方式严重影响效率。所以并不推荐使用。

  • Collections.synchronizedList(List list):实现了List的线程安全。

悲观锁 VS 乐观锁

img

悲观锁:每次获取数据时,都担心数据被修改,因此加独占锁,确保自己使用的过程中数据不会被别人修改,使用完成后解锁。期间对该数据读写的其他线程都会等待/阻塞。

  • 使用场景:写多读少,并发量不大。
  • 举例:如 synchronized 的实现、传统的关系型数据库如行锁、表锁,读锁、写锁。遵循一锁二判三更新四释放的原则。

  • 缺点:导致频繁的上下文切换和调度延时,降低性能;一个线程持有锁会导致其他所有需要此锁的线程挂起。

乐观锁:对于并发操作产生的线程安全问题持乐观状态,认为竞争不总是会发生,不需持有锁;每次获取数据时,都不担心数据被修改,因此读操作前不加锁,写操作时才判断数据在此期间是否被其他线程修改。如果发生修改,则返回写入失败,再重试(自旋);如果没有被修改,则执行修改操作,返回修改成功。

  • 使用场景:读多写少。
  • 举例:一般用CAS算法实现。
  • 缺点:操作期间该数据可被其他线程读写。
    • 乐观锁在获得锁的同时已完成了更新,校验逻辑易出现漏洞,另外,对冲突的解决策略有较复杂的要求,处理不当易造成系统压力或数据异常,所以资金相关的金融敏感信息不建议使用乐观锁更新。
  • 在项目开发中的实践:SVN、Git 等版本控制管理器,提交时对比版本号,如果远程仓库的版本号和本地的不一样表示有人已提交过代码,需要先更新到本地处理一下版本冲突问题。
行锁、表锁

见数据库文档中的 #锁机制。

CAS 比较-交换

Compare and Swap

原理:基于乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。当且仅当预期原值 A 和内存位置 V 的值相同时,才将 V 修改为新值 B 并返回true,否则处理器什么都不做。

  • 解决多线程并行情况下使用锁造成性能损耗的一种机制,这是硬件实现的原子操作。
  • 在操作共享变量时使用自旋锁,效率更高一些。
  • 底层是调用 Unsafe 类中的方法,都是操作系统提供的,由其他语言实现。

在 JUC 包下实现的很多类都用到了CAS操作:

  1. AbstractQueuedSynchronizer(AQS框架)
  2. AtomicXXX 类
产生的问题
  1. ABA 问题:

  2. 循环时间长开销大:并发量较高,资源竞争严重时,许多线程反复尝试更新某一个变量,CAS操作却又一直不成功(自旋),相当于死循环,浪费CPU 资源。

  3. 只能保证一个共享变量的原子操作:对多个共享变量无法保证。

    • 此时可用synchronized锁;可用AtomicReference来保证对象间的原子性,把多个对象放入CAS中。
ABA 问题

线程1准备将内存值由A改为C:

  1. 线程1从内存位置 V读取了数据A;
  2. 由于没有锁,线程2将内存位置 V 的值由A改为B,又由B改回A;
  3. 线程1通过CAS比较,发现数据仍是A没变,很容易认为在此期间没有线程修改过数据,就写成了C,返回成功;
  4. 如果C的值(与内存值存在运算关系?)在ABA的线程中发生改变,则不能实现预期结果。

解决:JUC包中 AtomicStampedReference 类(加入版本号,对比内存值+版本号)来解决 ABA 问题。

线程同步(并发加锁)

线程同步:两个或多个共享关键资源的线程并发执行。用于避免关键资源使用冲突。

  • 是为了保证多个线程之间的有序执行和数据的一致性。

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

线程间的同步、三种并发加锁方式

三种并发控制方法:

  1. 互斥量(Mutex):采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。如
    1. synchronized 关键字(内置锁)
    2. 各种 Lock(互斥锁、ReadWriteLock 读写锁、记录锁、自旋锁)。
  2. JUC 同步/并发工具类、核心类 AQS 常用的 AQS 组件/同步器
    1. 信号量(Semphares))
    2. ReentrantLock 可重入锁
    3. 阻塞队列、闭锁、栅栏、FutureTask
  3. 事件(Event)):Wait()/Notify() 通过通知的方式来保持多线程同步,方便比较多线程优先级。

另一种:

  1. 内置锁(synchronized)
  2. 重入锁(ReentrantLock)
  3. 读写锁(ReadWriteLock)
  4. 乐观锁
  5. 悲观锁
  6. 分布式锁

synchronized 关键字

用来控制线程同步:主要解决的是多个线程之间访问资源的同步性,可以保证(被它修饰的方法或者代码块)在任意时刻只能被一个线程执行。

  • 在 Java 早期版本中,synchronized 属于重量级锁,效率低。

参考:(二)彻底理解Java并发编程之Synchronized关键字实现原理剖析

synchronized 关键字用法总结:

  1. 修饰实例方法(同步方法、this 锁、当前实例锁):是给对象实例上锁,进入同步代码前要获得当前对象实例的锁。
  2. 修饰静态方法(class 锁、类对象锁):是给 Class 当前类加锁,作用于类的所有对象实例 ,进入同步代码前要获得当前 class 的锁。
  3. 修饰代码块(同步语句块、Object 锁、对象实例锁):是对括号里指定的对象/类加锁,表示进入同步代码块前要获得给定对象、Class 的锁。
  4. 不能修饰构造方法。
  5. 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。
修饰实例方法(同步方法)

给当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。

1
2
3
synchronized void method() {
    //业务代码
}
修饰静态方法

给当前类加锁,作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。

  • 因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。
1
2
3
synchronized static void method() {
    //业务代码
}

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?

  • 不互斥!

  • 如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象。

  • 因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁,二者不互斥。

修饰代码块(同步语句块)

对括号里指定的对象/类加锁:

  • synchronized(object):表示进入同步代码块前要获得 给定对象的锁。
  • synchronized(类.class):表示进入同步代码块前要获得 给定 Class 的锁。
1
2
3
synchronized(this) {
    //业务代码
}
不能修饰构造器

构造方法不能使用 synchronized 关键字修饰。不过,可以在构造方法内部使用 synchronized 代码块。

  • 另外,构造方法本身是线程安全的,但如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。
synchronized 底层原理

使用javap -v xxx.class命令进行反编译字节码去理解底层原理。

也要讲讲三种使用方式。

synchronized 对象锁采用互斥的方式让同一时刻至多有一个线程能持有对象锁。

synchronized 同步锁是通过 JVM 内置的 Monitor 监视器实现的,而监视器又是依赖操作系统的互斥锁 Mutex 实现的。线程获得锁需要使用对象(锁)关联 Monitor。

JVM 监视器的执行流程

Monitor 内部有三个属性:

  1. Owner:关联(存储)当前获得锁的线程,并且只能关联一个线程;
  2. EntryList、EntrySet:关联的是处于阻塞状态的线程;
  3. WaitSet:关联的是处于 waiting 状态的线程;

JVM 监视器的执行流程:

  1. 线程先通过自旋 CAS 的方式尝试获取锁,如果获取失败就进入 EntrySet 集合,如果获取成功就拥有该锁。
  2. 当调用 wait() 方法时,线程释放锁并进入 WaitSet 集合,等其他线程调用 notify 或 notifyAll 方法时再尝试获取锁。
  3. 锁使用完之后就会通知 EntrySet 集合中的线程,让它们尝试获取锁。

image.png

轻量级锁和偏向锁

在Java早期版本中,synchronized属于重量级锁,效率低下。

  • 因为monitor监视器锁,依赖于底层操作系统的Mutex Lock来实现,而操作系统实现线程之间的切换时,需要从用户态转换到内核态,这个切态过程需要较长的时间,并且更方面成本较高,这也是早期的synchronized性能效率低的原因。

  • 不过在Java6之后,Java官方从JVM层面对synchronized进行了优化,所以现在的synchronized锁,效率也十分不错了。为了减少获得锁、释放锁带来的性能消耗,引入了轻量级锁和偏向锁。

Java对象的构成

在JVM中,对象在内存中分为三块区域:对象头,实例数据和对齐填充。

ReentrantLock

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。

  • 能对共享/临界资源重复加锁,即当前线程再次获取该锁不会阻塞。调用 lock() 方法获取锁之后,再次调用 lock() 不会阻塞。
  • 实现原理:底层主要是由 CAS + AQS 队列来实现的。支持公平锁和非公平锁,默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。

  • 不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
实现原理

ReentrantLock 里面有一个内部类 Sync,Sync 继承自 AQS,添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。

1
2
3
4
5
6
7
8
9
10
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

public class ReentrantLock implements Lock, java.io.Serializable {}

1. 新建锁对象Lock l = new ReentrantLock();
2. 加锁 l.lock()
3. 解锁 l.unlock()

img

可重入锁

可重入锁 也叫递归锁:指的是线程可以再次获取自己的内部锁。

  • 比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的。如果是不可重入锁的话,就会造成死锁。

可重入锁包括:

  1. synchronized 关键字锁;
  2. JDK 提供的所有现成的 Lock 实现类(ReentrantLock 类、ReentrantReadWriteLock、等)。
公平锁、非公平锁
  • 公平锁 : 按线程在队列中(发出请求)的排队顺序获得锁,先到先得。
    • 锁被释放之后,先申请的线程先得到锁。
    • 性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
  • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁(发出请求后立即尝试获取锁),谁抢到就是谁的,获取失败才排队等待。
    • 锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。
    • 性能更好,但可能会导致某些线程永远无法获取到锁。
synchronized、ReentrantLock 区别
  1. 两者都是可重入锁;
  2. synchronized 依赖于 JVM 实现,而 ReentrantLock 依赖于 JDK 层面实现的(即 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成)。
  3. 相比synchronized,ReentrantLock增加了一些高级功能:等待可中断、可实现公平锁、可实现选择性通知(锁可以绑定多个条件)、支持超时。
可中断锁、不可中断锁
  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

ReentrantReadWriteLock

在实际项目中使用的并不多,面试中也问的比较少,简单了解即可。JDK 1.8 引入了性能更好的读写锁 StampedLock 。

ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。

  • 一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥。
  • 读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)。

ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁) 。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。

  • 和 ReentrantLock 一样,ReentrantReadWriteLock 底层也是基于 AQS 实现的。

  • ReentrantReadWriteLock 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显式地指定。

适合场景:由于 ReentrantReadWriteLock 既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。因此,在读多写少的情况下,使用 ReentrantReadWriteLock 能够明显提升系统性能。

共享锁、独占锁
  • 共享锁:一把锁可以被多个线程同时获得。
  • 独占锁:一把锁只能被一个线程获得。
线程持有读锁还能获取写锁吗?
  • 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
  • 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

读写锁的源码分析,推荐阅读 聊聊 Java 的几把 JVM 级锁 - 阿里巴巴中间件 这篇文章,写的很不错。

读锁为什么不能升级为写锁?

写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。

另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。

StampedLock

StampedLock 是 JDK 1.8 引入的性能更好的读写锁,不可重入且不支持条件变量 Condition。

不同于一般的 Lock 类,StampedLock 并不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 独立实现的(AQS 也是基于这玩意)。

1
2
public class StampedLock implements java.io.Serializable {
}

StampedLock 提供了三种模式的读写控制模式:

  • 写锁:独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于 ReentrantReadWriteLock 的写锁,不过这里的写锁是不可重入的。
  • 读锁 (悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于 ReentrantReadWriteLock 的读锁,不过这里的读锁是不可重入的。
  • 乐观读:允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。

另外,StampedLock 还支持这三种锁在一定条件下进行相互转换 。

性能为什么更好?

相比于传统读写锁多出来的乐观读是StampedLock比 ReadWriteLock 性能更好的关键原因。

StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。

适用场景

和 ReentrantReadWriteLock 一样,StampedLock 同样适合读多写少的业务场景,可以作为 ReentrantReadWriteLock的替代品,性能更好。

不过,需要注意的是StampedLock不可重入,不支持条件变量 Condition,对中断操作支持也不友好(使用不当容易导致 CPU 飙升)。如果你需要用到 ReentrantLock 的一些高级性能,就不太建议使用 StampedLock 了。

另外,StampedLock 性能虽好,但使用起来相对比较麻烦,一旦使用不当,就会出现生产问题。

底层原理

如果只是准备面试的话,建议多花点精力搞懂 AQS 原理即可,StampedLock 底层原理在面试中遇到的概率非常小。

StampedLock 不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 实现的(AQS 也是基于这玩意),CLH 锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock 通过 CLH 队列进行线程的管理,通过同步状态值 state 来表示锁的状态和类型。

StampedLock 的原理和 AQS 原理比较类似。

wait()、notify()、notifyAll() 通知

见 线程阻塞。

生产者消费者模型

作用:

  1. 通过平衡生产者的生产能力和消费者的消费能力来提升整个系统的运行效率;
  2. 解耦;

实现:

  • wait 和 notify
  • ReentrantLock
  • BlockQueue
  • Semaphore
  • PipedInputStream

并发容器与并发编程

Java 并发编程(JUC)

Unsafe 类

使 Java 拥有(类似 C 语言指针一样)操作内存空间的能力,是 Java 并发开发的基础。

主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等。方法的实现需依赖本地方法(Native Method)。

A’tomic 原子类

原子类:有原子操作特征的类,指一个操作是不可中断的。在多线程一起执行时,一个操作一旦开始,就不会被其他线程干扰。存放在 java.util.concurrent.atomic下。

四类原子类
  1. 基本类型:
    1. AtomicInteger:整型原子类
    2. AtomicLong:长整型原子类
    3. AtomicBoolean:布尔型原子类
  2. 数组类型:
    1. AtomicIntegerArray:整型数组原子类
    2. AtomicLongArray:长整型数组原子类
    3. AtomicReferenceArray:引用类型数组原子类
  3. 引用类型:
    1. AtomicReference:引用类型原子类
    2. AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可解决用 CAS 进行原子更新时可能出现的 ABA 问题。
    3. AtomicMarkableReference :原子更新带有标记位的引用类型
  4. 对象的属性修改类型:
    1. AtomicIntegerFieldUpdater:原子更新整型字段的更新器
    2. AtomicLongFieldUpdater:原子更新长整型字段的更新器
    3. AtomicReferenceFieldUpdater:原子更新引用类型字段的更新器
Atomic 类实现原理

原理:用 CAS + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,提升执行效率。

如 AtomicIntger 的实现原理:getAndIncrement() 方法:以原子方式将当前的值加1。

AtomicInteger 类常用方法
1
2
3
4
5
6
7
public final int get() // 获取当前的值
public final int getAndSet(int newValue) // 获取当前的值,并设置新的值
public final int getAndIncrement() // 获取当前的值,并自增
public final int getAndDecrement() // 获取当前的值,并自减
public final int getAndAdd(int delta) // 获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) // 如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue) // 最终设置为newValue,使用 lazySet 设置后可能导致其他线程在之后的一小段时间内还是可读到旧值。

AtomicInteger 类的使用示例:

使用 AtomicInteger 之后,不用对 increment() 方法加锁也可保证线程安全。

1
2
3
4
5
6
7
8
9
10
class AtomicIntegerTest {
    private AtomicInteger count = new AtomicInteger();
    // 使用AtomicInteger之后,不需对该方法加锁,也可实现线程安全。
    public void increment() {
        count.incrementAndGet();
    }
    public int getCount() {
        return count.get();
    }
}

JUC 核心类 AQS

AQS (AbstractQueuedSynchronizer) 抽象队列同步器:在 java.util.concurrent.locks 包下,是底层抽象同步工具类;

  • 利用先进先出 CLH 队列锁实现:AQS 内部维护了一个先进先出的双向队列,存储排队的线程。
  • 用途:用来构建锁和同步器的框架,(通过继承 AQS 类并重写其模版方法)能简单高效地构造出大量应用广泛的同步器。如: 常用的 AQS 组件/同步器。
AQS 原理

AQS原理图

AQS 核心思想是:

  1. AQS 用一个 volatile int state 变量作为共享资源:
    1. volatile 能保证多线程下的可见性;
    2. state=1 代表当前对象锁已被占有;
    3. state 状态信息通过 protected 类型的 getState,setState,compareAndSetState(CAS) CAS 进行操作来保证其并发修改的安全性;
  2. 如果被请求的共享资源空闲,获取成功执行临界区代码,则将当前请求资源的线程 1 设置为有效的工作线程,并将共享资源设置为锁定状态,释放资源时会通知/唤醒同步队列中的等待线程;
  3. 如果被请求的共享资源被占用,多线程争用资源被阻塞时,线程获取资源/锁失败,会进入 FIFO 等待(同步)队列挂起;AQS 作为线程阻塞等待及被唤醒时锁分配的机制。
AQS 的两种资源共享方式

AQS 定义两种资源共享方式:

  1. Exclusive(独占锁):每次只能有一个线程持有锁、能执行,如 ReentrantLock。
  2. Share(共享锁):多个线程可同时获取锁同时执行,并发访问共享资源,如 Semaphore、CountDownLatch、CyclicBarrier、ReadWriteLock。
常用的 AQS 组件、实现类
  1. ReentrantLock(可重入锁):能对共享/临界资源重复加锁,即当前线程再次获取该锁不会阻塞。基于AQS,唯一实现了Lock接口的类。详细见线程同步中的ReentrantLock。
    • 实现原理:
    • ReentrantReadWriteLock(读-写锁):可看作组合式,允许一个资源可被多个读/写操作访问,但读-写不能同时进行。允许多个线程同时对某一资源进行读。
  2. SynchronousQueue(同步队列)
  3. FutureTask

JUC 并发工具类

JUC 就是 java.util.concurrent 包:里面都是解决并发问题的一些定义类。

JUC下最常用的四大并发工具类(同步器)为:

  1. 'Semaphore(信号量):限制某段代码块的并发数,允许同一时刻多个线程同时访问同一资源,但需控制最大线程数量。

    • synchronized/ReentrantLock(相当于构造器参数最大并发数/计数器n=1)都是一次只允许一个线程访问某个资源。
  2. CountDownLatch(倒计时器):是一个同步工具类,用来协调多个线程间的同步。用来控制当前线程等待直到倒计时结束(其它线程都执行完毕),再开始执行。

    • 通过计数器实现,初始值是线程数量,每执行完一个线程-1。只能一次性使用。
  3. CyclicBarrier(循环栅栏、栅栏锁):和 CountDownLatch 类似,但可重复使用(reset)。让一组线程到达一个屏障(同步点)时被阻塞,直到最后一个线程到达屏障(所有线程的任务都执行完毕)时,才开门,所有被屏障拦截的线程才会继续干活。

    • 如:用多线程读取多个文件处理的场景。
  4. Exchanger(交换器):用于在线程之间交换数据,但是比较受限,因为只能两个线程之间交换数据。

    1. Exchanger提供了一个同步点,一对线程可以交换数据。每个线程通过exchange()方法的入口提供数据给他的伙伴线程,并接收他的伙伴线程提供的数据并返回。
    2. 当两个线程通过Exchanger交换了对象,这个交换对于两个线程来说都是安全的。
    3. Exchanger可以认为是 SynchronousQueue 的双向形式,在运用到遗传算法和管道设计的应用中比较有用。
0%