面试问题多线程版-Ⅰ

Interview

Posted by Ekko on August 5, 2020

文章内容主要来自优知学院

AQS部分参考知乎简书

结合其他相关内容汇总,包括锁、AQS等内容,方便查看

[TOC]


并发编程三要素

  1. 原子性: 一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败
  2. 有序性: 程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
  3. 可见性: 一个县城对共享变量的修改,另一个线程能够立刻看到

原子性

线程切换会带来原子性的问题。虽然读取和写入都是原子操作,但合起来就不属于原子操作,这种称为“复合操作”

可以用synchronized 或 Lock 来把这个复合操作“变成”原子操作,或者用java.util.concurrent.atomic里的原子变量类,可以确保所有对计数器状态访问的操作都是原子的

可见性

缓存导致可见性问题,即A线程在数据做了修改,但没有立刻更新到主内存中,而B线程去主内存中读取数据,导致数据不一致

synchronized或者Lock:保证同一个时刻只有一个线程获取锁执行代码,锁释放之前把最新的值刷新到主内存,实现可见性

有序性

有序性,即程序的执行顺序按照代码的先后顺序来执行

处理器为了拥有更好的运算效率,会自动优化、排序执行我们写的代码,但会确保执行结果不变,单线程下没有什么问题,但是多线程会出现预想不到的问题

synchronized和Lock能确保原子性,能让多线程执行代码的时候依次按顺序执行,自然就具有有序性

而volatile关键字也可以解决这个问题,volatile 关键字可以保证有序性,让处理器不会把这行代码进行优化排序


创建线程的有哪些方式

  • 继承Thread类创建线程类
  • 通过Runnable接口创建线程类
  • 通过Callable和Future创建线程

创建线程的三种方式的对比

  1. 采用实现Runnable、Callable接口的方式创建多线程

优势是:

线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。

在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想

劣势是:

编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法

  1. 使用继承Thread类的方式创建多线程

优势是:

编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程

劣势是:

线程类已经继承了Thread类,所以不能再继承其他父类

  1. Runnable和Callable的区别
  • Callable规定(重写)的方法是call(),Runnable规定(重写)的方法是run()
  • Callable的任务执行后可返回值,而Runnable的任务是不能返回值的
  • Call方法可以抛出异常,run方法不可以
  • 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果

线程的生命周期及状态

线程状态.png

  1. 新建状态(New):当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread()
  2. 就绪状态(Runnable):“可执行状态”,线程对象被创建后,其他线程调用了该对象的start()方法,线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了start()此线程立即就会执行
  3. 运行状态(Running):线程获取CPU权限进行执行,当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就 绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中
  4. 阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:

    • 等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态 wait()会释放同步锁(synchronized),属于Object方法 sleep()不会释放锁
    • 同步阻塞:线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态
    • 其他阻塞:通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态
  5. 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期

什么是线程池,有几种创建方式

线程池(Thread Pool)是一种基于池化思想管理线程的工具,经常出现在多线程服务器中,如MySQL

线程过多会带来额外的开销,其中包括创建销毁线程的开销、调度线程的开销等等,同时也降低了计算机的整体性能。线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用

java 提供了一个 java.util.concurrent.Executor接口的实现用于创建线程池

四种线程池的创建:

  1. newCachedThreadPool创建一个可缓存线程池
  2. newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数
  3. newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行
  4. newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务

线程池的优点

  • 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗
  • 提高响应速度:任务到达时,无需等待线程创建即可立即执行
  • 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控
  • 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行

Java中的同步集合与并发集合有什么区别

同步集合类:

  • Vector
  • Stack
  • HashTable
  • Collections.synchronized方法生成

并发集合类:

  • ConcurrentHashMap
  • CopyOnWriteArrayList
  • CopyOnWriteArraySet等

区别:

同步集合与并发集合都为多线程和并发提供了合适的线程安全的集合,不过并发集合的可扩展性更高。同步集合比并发集合会慢得多,主要原因是锁,同步集合会对整个Map或List加锁,而并发集合例如ConcurrentHashMap把整个Map 划分成几个片段,只对相关的几个片段上锁,同时允许多线程访问其他未上锁的片段(JDK1.8版本底层加入了红黑树)


synchronized 的作用

可以先把 synchronized 理解成一句人话:

给一段代码上锁,同一时刻只允许一个线程进去执行。

它最主要解决的是多个线程同时操作共享资源时,出现数据错乱的问题。

比如下面这个例子:

1
2
3
4
5
6
7
class Counter {
    private int count = 0;

    public void add() {
        count++;
    }
}

count++ 看起来只有一行,但它不是原子操作,大致可以拆成三步:

  1. 读取 count
  2. count + 1
  3. 写回 count

如果两个线程同时执行,就可能都读到旧值,最后只加了一次。

这时候就可以用 synchronized

1
2
3
4
5
6
7
class Counter {
    private int count = 0;

    public synchronized void add() {
        count++;
    }
}

加上之后,同一时刻只能有一个线程执行 add(),这样这个复合操作就被保护起来了。

synchronized 能保证什么

synchronized 主要能保证三件事:

  1. 原子性:被它保护的代码块,同一时刻只能有一个线程执行。
  2. 可见性:一个线程在同步代码块里修改了共享变量,退出同步块后,其他线程再次拿到同一把锁时能看到最新值。
  3. 有序性:在同步代码块内部,JVM 不会让这段临界区里的执行效果对持有同一把锁的线程表现得乱序。

所以面试里如果被问到:synchronized 解决了什么问题?

一个比较稳的回答是:

它通过加锁保证临界区同一时刻只被一个线程执行,从而保证原子性,同时借助进入锁和释放锁的内存语义保证可见性和一定程度上的有序性。

synchronized 锁的是什么

synchronized 的本质是“对某个对象监视器加锁”,所以关键不是这个关键字本身,而是你锁的是谁

  • 对于普通同步方法,锁的是当前实例对象,也就是 this
  • 对于静态同步方法,锁的是当前类对应的 Class 对象
  • 对于同步代码块,锁的是括号里显式传入的那个对象

例如:

1
2
public synchronized void methodA() {
}

等价于:

1
2
3
4
public void methodA() {
    synchronized (this) {
    }
}

再比如:

1
2
public static synchronized void methodB() {
}

它锁的是:

1
2
synchronized (当前类.class) {
}

使用 synchronized 时要注意什么

1. 锁对象必须是多个线程真正共享的那一个

下面这种写法几乎等于没加锁:

1
2
3
4
5
public void method() {
    synchronized (new Object()) {
        // do something
    }
}

因为每次进来都会 new 一个新对象,每个线程拿到的都不是同一把锁。

2. 锁的范围不要过大

锁的范围越大,并发度越低。能只锁核心共享逻辑,就不要把无关代码也一起锁住。

3. synchronized 是可重入锁

同一个线程已经拿到这把锁之后,再次进入这把锁保护的代码,不会把自己锁死。

1
2
3
4
5
6
synchronized void a() {
    b();
}

synchronized void b() {
}

这里 a() 里调用 b() 是没问题的,因为当前线程已经持有这把锁。

面试里怎么快速区分

  • synchronized 适合保护一整段临界区
  • 它可以保证原子性、可见性、有序性
  • 它是悲观锁思路,先加锁,再操作共享资源
  • 它不能提高并发,只能保证并发下结果正确

volatile关键字的作用

volatile 也可以先记一句人话:

一个线程改了值,别的线程马上能看到;并且这个变量相关的读写,不会被随意重排序。

它主要有两个作用:

  1. 保证可见性
  2. 禁止指令重排序

但是一定要记住一句最重要的话:

volatile 不能保证复合操作的原子性。

1
volatile public int i = 1;

当volatile变量i被赋值2时,这时线程1会做两件事:

  • 更新主内存
  • 向CPU总线发送一个修改信号

这时监听CPU总线的处理器会收到这个修改信号后,如果发现修改的数据自己缓存了,就把自己缓存的数据失效掉。这样其它线程访问到这段缓存时知道缓存数据失效了,需要从主内存中获取。这样所有线程中的共享变量i就达到了一致性

1. volatile 的可见性

看一个最经典的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
class TaskRunner {
    private volatile boolean running = true;

    public void stop() {
        running = false;
    }

    public void run() {
        while (running) {
            // do work
        }
    }
}

如果 running 不加 volatile,有可能线程 A 已经把它改成 false 了,但线程 B 还一直读到旧值 true,导致循环停不下来。

加上 volatile 后,线程 A 的修改会更快地对其他线程可见,线程 B 读到新值后就能结束循环。

所以 volatile 很适合这种场景:

  • 一个线程写
  • 多个线程读
  • 变量本身的赋值是独立、简单的

比如:

  • 开关标记
  • 状态位
  • 配置刷新标记

2. volatile 的有序性

volatile 还能禁止某些关键的指令重排序。

这个点最常见的场景是单例双重检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这里 instance 必须用 volatile,否则可能发生指令重排序,导致“对象引用已经赋值了,但对象还没完全初始化好”,其他线程就可能拿到一个未完全构造的对象。

3. 为什么 volatile 不能保证线程安全

很多人最容易误会这里。

例如:

1
2
3
4
5
6
7
class Counter {
    volatile int count = 0;

    public void add() {
        count++;
    }
}

虽然 countvolatile,但 count++ 依然不是原子操作。

它还是那三步:

如果两个线程同时执行,还是可能互相覆盖,最终结果依旧不对。

所以:

  • volatile 能保证你读到的是最新值
  • 但不能保证“读出来再改再写回去”这一整套动作是安全的

如果你要保护 i++check-then-act、余额扣减这类复合逻辑,就不能只靠 volatile,而要用:

  • synchronized
  • Lock
  • AtomicInteger 这类原子类

4. volatile 的底层可以怎么理解

不用背太底层的 CPU 细节,面试里能讲到下面这层就够用了:

  • volatile 变量的写,会及时刷新到主内存
  • 其他线程读取这个变量时,会从主内存重新读取最新值
  • JVM 会在 volatile 读写前后插入内存屏障,限制重排序

如果再展开一点,可以理解为缓存一致性协议会让其他 CPU 核心中对应缓存行失效,之后别的线程再读时,就会去拿新值。


volatile 可见性

可以把它理解成:

这个变量一旦被某个线程修改,其他线程下次读取时,不会一直抱着自己的旧缓存不放。

所以它解决的是“你改了,我看不见”的问题,不解决“你改的时候别人也在改”的问题。

1个线程修改完 volatile 修饰的值后,立即推送到主存。推送主存的过程中,要经过总线。其他cpu线程一直在嗅探总线的数据流通。在缓存一致性协议的保障下,其他线程能嗅探到这条数据修改,如果自己的缓存行有这条数据,将自己的缓存行置为不可用,如果下一个线程要用这个数据,需要先从主存拉取数据保存到自己的缓存行


volatile 禁止指令重排序

可以先把“指令重排序”理解成:编译器和 CPU 为了提高执行效率,可能会在不改变单线程结果的前提下,调整部分指令顺序。

单线程下通常没问题,但多线程下如果另一个线程刚好观察这个过程,就可能看到一个“中间状态”。

volatile 会通过内存屏障约束这种重排序。

常见记法是:

  • volatile 写前后会插入写屏障
  • volatile 读前后会插入读屏障

如果你只是为了面试回答,一般不用死背 StoreStoreStoreLoadLoadLoadLoadStore 这些名字,知道它的作用是限制重排序并保证内存可见性就够了。


问题一:cpu在嗅探总线的过程中,怎么知道这个变量是 volatile 修饰的

更准确一点的理解是:

  • volatile 是 Java 内存模型里的语义要求
  • JVM 在生成机器指令时,会选择合适的屏障或带锁前缀的指令来实现这种语义
  • 最终效果是让其他处理器核心感知到这次写入,并保证可见性和有序性

所以面试里不建议回答成“CPU先识别 Java 代码里有 volatile”,而是应该说:

JVM 会把 volatile 的语义翻译成底层的内存屏障和相关机器指令,底层硬件再配合缓存一致性协议完成可见性保证。


synchronized 和 volatile 怎么选

这个问题面试非常常见,可以直接这样记:

关键字 能保证什么 不能保证什么 典型场景
synchronized 原子性、可见性、有序性 不能提升并发性能 保护临界区、复合操作
volatile 可见性、有序性 不能保证原子性 状态标记、开关量、单次赋值

一句话总结:

  • 只需要让一个值对别的线程立刻可见,用 volatile
  • 需要把一整段逻辑作为一个整体保护起来,用 synchronized

什么是CAS

CAS全称Compare and swap,字面意思:”比较并交换“

CAS是一种基于锁的操作,而且是乐观锁,是一种无锁算法,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步

一个 CAS 涉及到以下操作:假设内存中的原数据V,旧的预期值A,需要修改的新值B

  • 比较 A 与 V 是否相等
  • 如果比较相等,将 B 写入 V
  • 返回操作是否成功

CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行


CAS的问题

ABA问题

就是一个值从A变成了B又变成了A,使用CAS操作不能发现这个值发生变化了

而这个问题的解决方案可以使用版本号标识,每操作一次version加1

在java5中,已经提供了AtomicStampedReference来解决问题

不能保证代码块的原子性

CAS机制所保证的知识一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了

性能问题

使用时大部分时间使用的是 while true 方式对数据的修改,直到成功为止。优势就是相应极快,但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间


Java死锁

死锁例子讲解可参考知乎

Java发生死锁的根本原因是:在申请锁时发生了交叉闭环申请

一般来说死锁的出现必须满足以下四个必要条件:

1. 互斥条件: 指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放

2. 请求和保持条件: 指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放

3. 不剥夺条件: 指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放

4. 环路等待条件: 指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源

要避免出现死锁的问题,只需要破坏四个条件中的任何一个就可以了


并发和并行的区别

一个cpu,多个线程切换,并发 多个cpu,多个线程,并行

如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。并发系统与并行系统这两个定义之间的关键差异在于 “存在” 这个词

如果程序能够并行执行,那么就一定是运行在多核处理器上

“并行”概念是“并发”概念的一个子集


AQS

AQS(AbstractQueuedSynchronizer)就是一个抽象的队列同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它

AQS的主要作用是为Java中的并发同步组件提供统一的底层支持,比如大家熟知的:

  • ReentrantLock
  • Semaphore
  • CountDownLatch
  • CyclicBarrier

等并发类均是基于AQS来实现的

可以先把 AQS 理解成一句人话:

AQS 不是具体的锁,而是一套“怎么抢资源、抢不到就排队、被唤醒后再继续抢”的通用框架。

如果只看源码,容易看到一堆 stateheadtailNode,但脑子里没有画面。其实你可以把它想成一个“门口管理员 + 等候队列”:

  • state:资源现在是不是可用
  • tryAcquire / tryRelease:线程能不能拿到资源、什么时候释放资源
  • CLH队列:没抢到资源的线程先去排队
  • park / unpark:排队的线程先休息,轮到它时再叫醒

所以 AQS 主要就是解决两个问题:

  1. 当前线程能不能拿到共享资源?
  2. 如果现在拿不到,线程应该怎么排队、怎么挂起、谁来唤醒它?

先看懂一张图

下面这张图可以先帮助你建立一个整体印象:

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
26
27
28
29
30
31
32
33
                AQS 独占锁工作主线

线程A来抢锁
    │
    v
tryAcquire() 成功?
    │
 ┌──┴──┐
 │ 是  │-------------------------> A拿到锁,开始执行临界区代码
 └─────┘
 │ 否
 v
把当前线程包装成 Node
加入等待队列尾部 tail
    │
    v
park 挂起当前线程
    │
    v
前面的线程释放锁 release()
    │
    v
unpark 唤醒 head 的后继节点
    │
    v
被唤醒的线程再次 tryAcquire()
    │
 ┌──┴──┐
 │ 成功│----> 设置自己为新的 head,继续执行
 └─────┘
 │ 失败
 v
继续留在队列里等待下一次唤醒

可以把上面的过程记成一句话:

先抢,抢不到就入队并阻塞;前面的线程释放资源后,再唤醒后继节点重新竞争。

比如java.util.concurrent.locks.ReentrantLock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ReentrantLock implements Lock, java.io.Serializable {
    private static final long serialVersionUID = 7373984872572414699L;
    /** Synchronizer providing all implementation mechanics */
    private final Sync sync;

    /**
     * Base of synchronization control for this lock. Subclassed
     * into fair and nonfair versions below. Uses AQS state to
     * represent the number of holds on the lock.
     */
    abstract static class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = -5179523762034025860L;

        /**
         * Performs {@link Lock#lock}. The main reason for subclassing
         * is to allow fast path for nonfair version.
         */
        abstract void lock();
        ...
        ...
        ...
    }

AQS的数据模型 也就是说,像 ReentrantLock 这样的并发工具,本身关注的是“什么条件下算拿到锁”,而排队、唤醒、状态管理这些通用细节,交给 AQS 去做。

AQS的数据模型.png

AQS 使用上图的资源变量 state来表示同步状态,通过内置的 CLH FIFO 队列来完成获取资源线程的排队工作,这里会涉及到三个要素

AQS 最核心的三个东西

1. state:资源状态

state 是一个整数,但不同组件对它的含义定义不同:

  • ReentrantLock 来说,state = 0 一般表示没加锁,state > 0 表示已经被占用
  • 对可重入锁来说,state 还能顺便表示重入次数
  • Semaphore 来说,state 可以表示许可证数量
  • CountDownLatch 来说,state 可以表示剩余计数

所以 state 不是“固定含义”的变量,它更像是 AQS 留给子类的一块“共享资源状态位”。

2. head / tail:等待队列的头尾

抢资源失败的线程不会一直傻等,它会被包装成一个 Node 节点,放进队列里。

  • head 指向队头
  • tail 指向队尾

新来的失败线程通常从队尾入队,等前面的线程释放资源后,再按顺序唤醒后继节点。

3. Node:排队中的线程

AQS 队列里不是直接塞 Thread,而是把线程包装成一个 Node 节点。

这个节点除了保存线程本身,还会保存:

  • 前驱节点 prev
  • 后继节点 next
  • 当前等待状态 waitStatus
  • 当前是独占模式还是共享模式

所以可以把 Node 理解成:线程在线程等待队列里的“座位号 + 状态卡片”。 java.util.concurrent.locks.AbstractQueuedSynchronizer

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
26
27
28
public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
    /**
    * Head of the wait queue, lazily initialized.  Except for
    * initialization, it is modified only via method setHead.  Note:
    * If head exists, its waitStatus is guaranteed not to be
    * CANCELLED.
    */
    // 队头结点
    private transient volatile Node head;

    /**
    * Tail of the wait queue, lazily initialized.  Modified only via
    * method enq to add new wait node.
    */
    // 队尾结点
    private transient volatile Node tail;

    /**
    * The synchronization state.
    */
    //共享资源变量state
    private volatile int state;
    ...
    ...
    ...
}

head、tail、state三个变量都是volatile的,通过volatile来保证共享变量的可见性

你可以把这三个字段先记成:

  • state:资源是否可用
  • head:当前轮到谁后面那个线程最有机会被唤醒
  • tail:新来的失败线程往哪里排

state: 它是int数据类型的,其访问方式有3种:

  • getState()
  • setState(int newState)
  • compareAndSetState(int expect, int update)

java.util.concurrent.locks.AbstractQueuedSynchronizer

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
26
27
28
29
30
31
32
33
34
35
36
/**
* Returns the current value of synchronization state.
* This operation has memory semantics of a {@code volatile} read.
* @return current state value
*/
// 具有内存读可见性语义
protected final int getState() {
    return state;
}

/**
* Sets the value of synchronization state.
* This operation has memory semantics of a {@code volatile} write.
* @param newState the new state value
*/
// 具有内存写可见性语义
protected final void setState(int newState) {
    state = newState;
}

/**
* Atomically sets synchronization state to the given updated
* value if the current state value equals the expected value.
* This operation has memory semantics of a {@code volatile} read
* and write.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that the actual
*         value was not equal to the expected value.
*/
// 具有内存读/写可见性语义
protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

这里最关键的是 compareAndSetState,也就是 CAS。

因为多个线程可能同时来抢资源,所以不能直接“看一眼 state 再改”,而要用 CAS 保证:

只有当 state 还是我预期的值时,我这次修改才算成功。

比如两个线程同时看到 state = 0,只有一个线程能 CAS 成功把它改成 1,另一个线程就会失败,然后进入排队逻辑。

AQS资源的两种共享方式

独占锁Exclusive:

独占模式下时,其他线程试图获取该锁将无法取得成功,只有一个线程能执行,如ReentrantLock采用独占模式

独占锁Exclusive.png

ReentrantLock还可以分为公平锁和非公平锁

  • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
  • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的

共享锁shared:

多个线程获取某个锁可能会获得成功,多个线程可同时执行,如:Semaphore、CountDownLatch

共享锁shared.png

可以先这样区分:

  • 独占模式:一次只让一个线程通过,像单人闸机
  • 共享模式:一次允许多个线程通过,像多张票同时放行

这也是为什么同样是 AQS,不同组件表现差异会很大,因为它们对 state 的解释方式和获取资源的规则不同。

AQS将大部分的同步逻辑均已经实现好,继承的自定义同步器只需要实现state的获取(acquire)和释放(release)的逻辑代码就可以,主要包括下面方法:

  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false
  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它

AQS需要子类复写的方法均没有声明为abstract,目的是避免子类需要强制性覆写多个方法,因为一般自定义同步器要么是独占方法,要么是共享方法,只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可

也就是说,AQS 已经把“排队、阻塞、唤醒”这些公共流程写好了,子类只需要回答:

  • 什么条件下算拿到资源?
  • 什么条件下算释放资源?
  • 释放之后要不要继续唤醒后继节点?

AQS的锁获取与释放原理

AQS的锁获取与释放原理.png

  1. 线程获取锁流程:
    • 线程A获取锁,state将0置为1,线程A占用
    • 在A没有释放锁期间,线程B也来获取锁,线程B获取state为1,表示线程被占用,线程B创建Node节点放入队尾(tail),并且阻塞线程B
    • 同理线程C获取state为1,表示线程被占用,线程C创建Node节点,放入队尾,且阻塞线程
  2. 线程释放锁流程:
    • 线程A执行完,将state从1置为0
    • 唤醒下一个Node B线程节点,然后再删除线程A节点
    • 线程B占用,获取state状态位,执行完后唤醒下一个节点 Node C,再删除线程B节点

如果你对“队列怎么管理线程”还是没感觉,可以再看一张更细的图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
初始状态:
state = 1,线程A持有锁

等待队列:
head -> [dummy] <-> [B] <-> [C] <- tail

说明:
- A 正在执行,不在等待队列里排队
- B、C 抢锁失败后进入队列
- dummy 是一个哑节点,方便统一处理队列逻辑

当 A 释放锁:
1. state 变回可用
2. AQS 唤醒 head 的后继节点 B
3. B 被唤醒后再次 tryAcquire()
4. 如果成功,B 成为新的持有者
5. 队列头向后移动

变化后:
head -> [B] <-> [C] <- tail

接着 B 执行完,再唤醒 C

这里有两个特别容易误会的点:

  1. 入队不代表立刻执行 入队只是说明“先到等待区排好队”,真正执行还得等前面的线程释放资源。

  2. 被唤醒也不代表一定马上成功 被唤醒后,线程通常还要再试一次 tryAcquire();成功了才真正拿到资源。


CLH队列(FIFO)

The wait queue is a variant of a "CLH" (Craig, Landin, and Hagersten) lock queue. CLH locks are normally used for spinlocks.

等待队列是“ CLH”(Craig Landin Hagersten)锁定队列,CLH锁通常用于自旋锁 (自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环)

CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程

在CLH同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、状态(waitStatus)、前驱节点(prev)、后继节点(next),AQS是通过内部类Node来实现FIFO队列的

可以把 CLH 队列理解成一个“排号队列”:

  • 新来的失败线程,先去队尾拿号
  • 只有排在前面的线程释放资源,后面的线程才有机会继续
  • 队列本身不负责“把锁给谁”,它负责的是“谁先等、谁后等、该唤醒谁”

所以 AQS 并不是“队列里第一个节点天然已经拿到锁”,而是:

队列维护等待顺序,真正能不能拿到资源,还是要靠 tryAcquire() 再判断一次。

java.util.concurrent.locks.AbstractQueuedSynchronizer

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
static final class Node {
    
    // 表明节点在共享模式下等待的标记
    static final Node SHARED = new Node();
    // 表明节点在独占模式下等待的标记
    static final Node EXCLUSIVE = null;

    // 表征等待线程已取消的
    static final int CANCELLED =  1;
    // 表征需要唤醒后续线程
    static final int SIGNAL    = -1;
    // 表征线程正在等待触发条件(condition)
    static final int CONDITION = -2;
    // 表征下一个acquireShared应无条件传播
    static final int PROPAGATE = -3;

    /**
     *   SIGNAL: 当前节点释放state或者取消后,将通知后续节点竞争state。
     *   CANCELLED: 线程因timeout和interrupt而放弃竞争state,当前节点将与state彻底拜拜
     *   CONDITION: 表征当前节点处于条件队列中,它将不能用作同步队列节点,直到其waitStatus被重置为0
     *   PROPAGATE: 表征下一个acquireShared应无条件传播
     *   0: None of the above
     */
    volatile int waitStatus;
    
    // 前继节点
    volatile Node prev;
    // 后继节点
    volatile Node next;
    // 持有的线程
    volatile Thread thread;
    // 链接下一个等待条件触发的节点
    Node nextWaiter;

    // 返回节点是否处于Shared状态下
    final boolean isShared() {
        return nextWaiter == SHARED;
    }

    // 返回前继节点
    final Node predecessor() throws NullPointerException {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }
    
    // Shared模式下的Node构造函数
    Node() {  
    }

    // 用于addWaiter
    Node(Thread thread, Node mode) {  
        this.nextWaiter = mode;
        this.thread = thread;
    }
    
    // 用于Condition
    Node(Thread thread, int waitStatus) {
        this.waitStatus = waitStatus;
        this.thread = thread;
    }
}

这里不用一开始就死记所有状态值,先重点记两个就够了:

  • SIGNAL = -1:前驱节点释放资源时,要记得唤醒我
  • CANCELLED = 1:这个节点不等了,作废了

平时看源码时,先抓住这几个字段最有帮助:

  • thread:当前节点对应哪个线程
  • prev / next:它在队列里的前后关系
  • waitStatus:它现在是不是还在有效等待

CLH同步队列遵循FIFO,首节点的线程释放同步状态后,将会唤醒它的后继节点(next),而后继节点将会在获取同步状态成功时将自己设置为首节点。你可以把这个过程理解成:

前面的人走了,叫醒后面第一个有效等待的人;后面这个人如果成功拿到资源,就成为新的“队头代表”。

独占式同步状态获取

acquire(int arg)方法为AQS提供的模板方法,该方法为独占式获取同步状态,但是该方法对中断不敏感,也就是说由于线程获取同步状态失败加入到CLH同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移除

  • 首先线程通过tryAcquire(arg)尝试获取独占资源,若获取成功则直接返回,若不成功,则将该线程以独占模式添加到等待队列尾部,tryAcquire(arg)由继承AQS的自定义同步器来具体实现
  • 当前线程加入等待队列后,会通过acquireQueued方法基于CAS自旋不断尝试获取资源,直至获取到资源
  • 若在自旋过程中,线程被中断过,acquireQueued方法会标记此次中断,并返回true
  • 若acquireQueued方法获取到资源后,返回true,则执行线程自我中断操作selfInterrupt()

把独占式获取过程翻译成人话,就是:

  1. 我先试试能不能直接拿到锁
  2. 如果拿不到,就排到队尾
  3. 排队后不是一直空转,而是合适的时候挂起自己
  4. 当前面线程释放资源时,我会被唤醒
  5. 被唤醒后再试一次,成功了就继续执行

这个过程不是“线程一直疯狂占 CPU 自旋”,而是“短暂尝试 + 排队等待 + 被唤醒后再竞争”。

独占式释放资源

AQS的释放资源过程,其入口函数为:

1
2
3
4
5
6
7
8
9
10
11
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        // 获取到等待队列的头结点h
        Node h = head;
        // 若头结点不为空且其ws值非0,则唤醒h的后继节点
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

通过tryRelease(arg)来释放资源,和tryAcquire类似,tryRelease也是有继承AQS的自定义同步器来具体实现

独占式释放也可以翻译成人话:

  1. 当前线程先释放自己占有的资源
  2. 如果确认资源真的可用了
  3. 就去唤醒等待队列里的后继节点
  4. 后继节点醒来后再自己去抢

注意这里也不是“释放线程直接把锁塞给下一个线程”,而是唤醒下一个线程,让它自己重新竞争

获取资源(共享模式)

方法入口:

1
2
3
4
public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

执行tryAcquireShared方法获取资源,若获取成功则直接返回,若失败,则进入等待队列,执行自旋获取资源,具体由doAcquireShared方法来实现

共享模式和独占模式最大的区别是:

  • 独占模式:一个线程成功,其他线程都得等
  • 共享模式:一个线程成功后,可能还有剩余资源,后面的线程也可能继续成功

这就是为什么像 Semaphore 这种组件能允许多个线程同时通过。

释放资源(共享模式)

方法入口:

1
2
3
4
5
6
7
8
9
public final boolean releaseShared(int arg) {
    // 尝试释放资源
    if (tryReleaseShared(arg)) {
        // 唤醒后继节点的线程
        doReleaseShared();
        return true;
    }
    return false;
}

tryReleaseShared(int)由继承AQS的自定义同步器来具体实现

共享模式释放资源后,AQS 可能会继续传播唤醒后续节点,所以共享模式下你经常会看到“一个线程放行后,后面几个线程也跟着被放行”的效果。

AQS 到底是怎么管理线程的

如果只记一版最简答案,可以记下面这套:

  1. 线程先尝试通过 tryAcquire / tryAcquireShared 直接获取资源
  2. 获取失败,就把线程包装成 Node 加到队尾
  3. 入队后线程不会一直忙等,而是会被挂起
  4. 前面的线程释放资源时,会唤醒后继节点
  5. 被唤醒的线程再次尝试获取资源
  6. 成功则出队并继续执行,失败则继续留在队列里等

所以 AQS 的队列本质上管理的是:

  • 等待顺序
  • 阻塞与唤醒
  • 谁最有资格在下一轮去竞争资源

而不是简单地“把锁对象放在队列里传来传去”。

ReentrantLock 基于 AQS 的完整时序图

如果把前面的概念真正串起来,ReentrantLock 在独占模式下的一次完整工作过程,大致可以理解成下面这样:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
时间线从上到下

线程A                          AQS/Sync                         线程B
 │                               │                               │
 │ lock()                        │                               │
 │------------------------------>│                               │
 │                               │ tryAcquire(1)                │
 │                               │ state: 0 -> 1 成功           │
 │<------------------------------│ 获取成功                      │
 │ 执行业务代码                  │                               │
 │                               │                               │
 │                               │                               │ lock()
 │                               │<------------------------------│
 │                               │ tryAcquire(1) 失败           │
 │                               │ addWaiter(Node) 入队         │
 │                               │ tail -> Node(B)              │
 │                               │ acquireQueued()              │
 │                               │ park 挂起 B                  │
 │                               │------------------------------>│ 阻塞等待
 │                               │                               │
 │ unlock()                      │                               │
 │------------------------------>│                               │
 │                               │ tryRelease(1)                │
 │                               │ state: 1 -> 0                │
 │                               │ unparkSuccessor(head)        │
 │                               │------------------------------>│ 唤醒 B
 │                               │                               │
 │                               │                               │ 再次 tryAcquire(1)
 │                               │                               │ state: 0 -> 1 成功
 │                               │<------------------------------│
 │                               │ setHead(Node(B))             │
 │                               │ B 成为新的有效头结点         │
 │                               │                               │ 执行业务代码
 │                               │                               │
 │                               │                               │ unlock()
 │                               │<------------------------------│
 │                               │ tryRelease(1)                │
 │                               │ 若后面还有节点,继续唤醒      │

这张图里最关键的不是记每个方法名,而是记住这条主线:

  1. lock()
  2. AQS 先尝试 tryAcquire()
  3. 失败就入队
  4. 入队后挂起
  5. 前一个线程 unlock() 后唤醒后继节点
  6. 被唤醒的线程再试一次 tryAcquire()
  7. 成功后继续执行

如果你还是想更直观一点,可以把它想成银行取号:

  • 柜台空着,直接办业务
  • 柜台有人,占不到号就先排队
  • 排队的人先坐着等,不会一直站在柜台前抢
  • 前一个人办完,叫下一个号
  • 下一个号来到柜台,再正式开始处理

AQS、synchronized、CAS 三者关系

这三个词很容易在面试里被混到一起,但它们不是一个层级的东西。

可以先看一句最短结论:

  • CAS 是一种原子更新手段
  • AQS 是一套同步器框架
  • synchronized 是 Java 语言层面提供的内置锁机制

1. CAS 是底层原子操作工具

CAS 关注的是:

“某个值还是不是我刚才看到的那个值?如果是,我就把它改掉。”

所以它解决的是“多线程同时修改同一个状态值时,怎么安全地更新”。

AQS 内部就大量使用 CAS 来做这些事情,比如:

  • 修改 state
  • 初始化队列
  • 更新 tail
  • 某些节点状态变更

也就是说,CAS 更像是 AQS 的底层工具之一。

2. AQS 是基于 CAS + 队列 + park/unpark 的同步框架

AQS 不直接等于锁,它更像一个“搭锁的底盘”。

它负责的事情包括:

  • 维护同步状态 state
  • 管理等待队列
  • 处理线程挂起和唤醒
  • 提供独占模式和共享模式

像这些类,很多都建立在 AQS 之上:

  • ReentrantLock
  • Semaphore
  • CountDownLatch
  • ReentrantReadWriteLock

所以可以说:

AQS 往下会用到 CAS,往上会支撑各种并发组件。

3. synchronized 不是基于 AQS 实现的

这个点特别容易被误会。

synchronized 是 JVM 原生支持的关键字,它依赖的是对象头、Monitor、字节码指令 monitorenter / monitorexit 这一套机制。

所以:

  • ReentrantLock 主要走的是 AQS 这条路
  • synchronized 主要走的是 JVM Monitor 这条路

它们都能实现互斥和同步,但底层实现路线不同。

4. 三者怎么放在一张脑图里

可以直接记下面这张关系图:

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
26
27
28
29
                Java 并发同步相关概念

                     ┌──────────────┐
                     │     CAS      │
                     │ 原子更新手段 │
                     └──────┬───────┘
                            │
                            v
                  ┌──────────────────┐
                  │       AQS        │
                  │ 同步器通用框架   │
                  │ state + 队列 +   │
                  │ park/unpark      │
                  └──────┬───────────┘
                         │
         ┌───────────────┼────────────────┐
         v               v                v
 ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
 │ReentrantLock │ │  Semaphore   │ │CountDownLatch│
 └──────────────┘ └──────────────┘ └──────────────┘


 另一条路线:

 ┌──────────────────────────────┐
 │         synchronized         │
 │ JVM Monitor 机制             │
 │ monitorenter/monitorexit     │
 └──────────────────────────────┘

5. 面试里怎么一句话回答

如果面试官问这三者关系,你可以这样答:

CAS 是原子更新的基础手段,AQS 是基于 CAS、等待队列和线程挂起唤醒机制实现出来的同步器框架,ReentrantLock 等并发组件建立在 AQS 之上;而 synchronized 是 JVM 原生的 Monitor 机制,不依赖 AQS,但它和 AQS 路线最终都在解决并发场景下的互斥与同步问题。


公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁

有可能会造成优先级反转或者饥饿现象

对于 Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大

对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过 AQS 的来实现线程调度,所以并没有任何办法使其变成公平锁

ReentrantLock 公平锁 vs 非公平锁 对比流程图

先记一句最核心的话:

  • 公平锁:先看队列里有没有人在等,有就老老实实排队
  • 非公平锁:先不管队列,先直接抢一下,抢到了再说
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
                 ReentrantLock 公平锁 vs 非公平锁

线程来执行 lock()
        │
        v
┌──────────────────────┬──────────────────────┐
│      公平锁 Fair     │    非公平锁 Nonfair  │
├──────────────────────┼──────────────────────┤
│ 1. 先检查等待队列     │ 1. 先直接 CAS 抢 state│
│    是否已有前驱节点   │                      │
│                      │                      │
│ 2. 如果前面有人在等   │ 2. 抢到了:直接成功   │
│    就不能插队         │    不管队列里是否有人 │
│                      │                      │
│ 3. 只有“队列没人”且   │ 3. 没抢到:再进入     │
│    “state可用”时      │    正常排队流程       │
│    才尝试获取锁       │                      │
│                      │                      │
│ 4. 获取失败则入队     │ 4. 入队后再等待唤醒   │
│    等待唤醒           │    并重新竞争         │
└──────────────────────┴──────────────────────┘

如果拆成两条更细的路径,可以这样看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
公平锁:

lock()
  │
  v
检查 hasQueuedPredecessors()
  │
  ├─ 有前驱节点 -> 不能插队 -> 入队等待
  │
  └─ 没前驱节点
        │
        v
     tryAcquire()
        │
        ├─ 成功 -> 获得锁
        └─ 失败 -> 入队等待
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
非公平锁:

lock()
  │
  v
先直接 CAS 抢 state
  │
  ├─ 成功 -> 立刻获得锁
  │
  └─ 失败
        │
        v
     再走 acquire()
        │
        v
     入队等待 / 被唤醒后再竞争

它们最本质的区别,不是在“是否使用队列”,而是在:

  • 公平锁:先看有没有人排队,再决定我能不能抢
  • 非公平锁:先抢一次,抢不到再去排队

为什么非公平锁吞吐量更高

因为非公平锁少了一次“严格按排队顺序执行”的约束。

有时候锁刚被释放,后来的线程如果正好在 CPU 上运行,就可以立刻抢到锁,不用一定先唤醒并等待队列里的老线程完成切换,所以整体吞吐量通常更高。

但代价就是:

  • 可能出现后来的线程插队
  • 等待时间不那么均匀
  • 极端情况下可能让队列里的线程更久才能拿到锁

面试里怎么一句话说清

可以直接这样答:

公平锁会先判断等待队列里是否已有前驱节点,有的话当前线程不能插队;非公平锁则会先直接尝试 CAS 抢锁,失败后才进入队列。所以非公平锁吞吐量通常更高,但公平性更差。


可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁

对于 Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁,其名字是Re entrant Lock重新进入锁。对于Synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁

1
2
3
4
5
6
7
8
9
10
// setA() 外层方法
synchronized void setA() throws Exception{
    Thread.sleep(1000);
    setB();
}

// setB() 内层方法
synchronized void setB() throws Exception{
    Thread.sleep(1000);
}

上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB 可能不会被当前线程执行,可能造成死锁


独享锁/共享锁

独享锁是指该锁一次只能被一个线程所持有

共享锁是指该锁可被多个线程所持有

对于 Java ReentrantLock而言,其是独享锁。但是对于 Lock 的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。独享锁与共享锁也是通过 AQS 来实现的,通过实现不同的方法,来实现独享或者共享。Synchronized属于独享锁


互斥锁/读写锁

独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。互斥锁在 Java 中的具体实现就是ReentrantLock 读写锁在 Java 中的具体实现就是ReadWriteLock


什么是乐观锁和悲观锁

乐观锁: 总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新数据

可以用版本号机制和CAS算法实现,适用于多读的应用类型,可以提高吞吐量,Java中java.unit.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现

版本号机制:一般在数据表中加上一个数据版本号Version字段,表示数据被修改的次数

悲观锁: 总是假设最坏的情况,每次去拿数据都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享数据每次只给一个线程使用,其他线程阻塞,用完后再把资源让给其他线程)

传统的关系型数据库里边就用到很多这种锁机制,比如行锁、表锁、读锁和写锁等,都是在操作之前先上锁,Java中synchronized(关键字)和 ReentrantLock(类)等独占锁就是悲观锁实现


分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁

对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作

ConcurrentHashMap来说一下分段锁的含义以及设计思想

ConcurrentHashMap中的分段锁称为 Segment,它即类似于 HashMap(JDK7 与 JDK8 中 HashMap 的实现)的结构,即内部拥有一个 Entry 数组,数组中的每个元素既是一个链表,同时又是一个 ReentrantLock(Segment 继承了 ReentrantLock)

当需要 put 元素的时候,并不是对整个 hashmap 进行加锁,而是先通过 hashcode 来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程 put 的时候,只要不是放在一个分段中,就实现了真正的并行的插入

但是,在统计 size 的时候,可就是获取 hashmap 全局信息的时候,就需要获取所有的分段锁才能统计

分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作

偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对 synchronized。更准确地说,这是 HotSpot JVM 为了优化 synchronized 而做的一套锁状态演进机制,常见讨论主要集中在 JDK 6 之后的实现。

很多人看到这里会卡住:“对象头到底在哪?”

可以先直接记一句:

对象头不是一块单独放在别处的东西,它就在 Java 对象自己的内存布局最前面。

也就是说,一个对象在堆里不是只有“成员变量”,它大致可以分成下面几部分:

1
2
3
4
5
6
7
8
9
对象在内存中的大致布局

┌──────────────────────┐
│      对象头 Header    │
├──────────────────────┤
│   实例数据 Instance   │
├──────────────────────┤
│   对齐填充 Padding    │
└──────────────────────┘

所以你 new 出来的对象,比如:

1
Object lock = new Object();

它在堆里其实就像这样:

1
2
3
4
5
6
7
8
9
10
11
┌──────────────────────────────────────┐
│ 对象头                               │
│ - Mark Word                          │
│ - Klass Pointer                      │
│ - (数组对象还会多一个长度字段)        │
├──────────────────────────────────────┤
│ 实例数据                             │
│ - 你定义的成员变量                    │
├──────────────────────────────────────┤
│ 对齐填充                             │
└──────────────────────────────────────┘

所以这里的“对象头”不是抽象概念,而是对象本身在堆内存里的头部区域

再结合 synchronized 去理解:

  • synchronized 锁住的是某个对象
  • 这个对象的运行时锁信息,会体现在这个对象头里的 Mark Word
  • JVM 会根据竞争情况,修改 Mark Word 中和锁相关的标记位或指针信息

所以原来那句“这三种锁的状态是通过对象监视器在对象头中的字段来表明的”,可以更准确地理解成:

锁状态相关信息主要体现在对象头里的 Mark Word 中;当锁膨胀为重量级锁后,又会进一步关联到 Monitor 对象。

对象头里最关键的是什么

先不用把对象头的所有细节一次背下来,先记住两个关键词就够了:

  1. Mark Word
  2. Klass Pointer

其中和锁最相关的是 Mark Word

  • Mark Word:存放对象运行时信息,比如哈希码、GC 分代年龄、锁标志位等
  • Klass Pointer:指向这个对象对应的类元数据,JVM 通过它知道“这个对象到底是什么类型”

如果是数组对象,还会多一块数组长度信息,因为 JVM 还得知道数组有多长。

为什么锁信息会放在对象头里

因为 synchronized 的锁对象本来就是“对象本身”。

所以 JVM 最自然的做法就是:把和锁相关的状态直接记在这个对象自己的头部信息里。这样当线程来竞争这个对象锁时,JVM 只要先看这个对象头,就能知道:

  • 现在是不是无锁状态
  • 是否已经偏向某个线程
  • 是否处于轻量级锁状态
  • 是否已经膨胀成重量级锁

你可以把对象头想成对象随身带的一张“状态卡片”,JVM 在加锁、解锁、升级锁的时候,会不断改这张卡片上的内容。

那如果锁的不是普通对象,而是类呢

这个问题也很常见,但结论其实很统一:

synchronized 本质上永远锁的是对象。所谓“锁类”,实际锁的是这个类对应的 Class 对象。

例如:

1
2
3
4
public class UserService {
    public static synchronized void test() {
    }
}

它本质上等价于:

1
2
3
4
5
6
public class UserService {
    public static void test() {
        synchronized (UserService.class) {
        }
    }
}

所以这里并不是在锁一个抽象的“类定义”,而是在锁 UserService.class 这个真实存在的对象。

UserService.class 本身也是一个对象,它的类型是 java.lang.Class,因此它同样有:

  • 对象头
  • Mark Word
  • 对应的锁状态变化

所以如果你写的是:

  • synchronized (this),那看的是当前实例对象的对象头
  • synchronized (UserService.class),那看的是 UserService.class 这个 Class 对象的对象头

本质上没有变,变的只是“锁对象是谁”。

可以直接记成:

1
2
3
普通对象锁 -> 锁实例对象
类锁       -> 锁 Xxx.class 对象
本质上都还是锁对象

再补一个最容易混淆的点:

1
2
3
4
5
6
7
public class Demo {
    public synchronized void a() {
    }

    public static synchronized void b() {
    }
}

这里两把锁不是同一把:

  • a() 锁的是某个 Demo 实例
  • b() 锁的是 Demo.class

所以一个线程进实例同步方法,另一个线程进静态同步方法,不一定互斥,因为它们抢的可能根本不是同一个对象锁。

常见加锁写法对照表

后面看到各种 synchronized 写法时,可以直接对照下面这张表:

写法 锁的是谁 是否每次都是同一把锁 典型场景 易错点
synchronized(this) 当前实例对象 同一个实例内是 保护当前对象的实例状态 不同实例之间不互斥
synchronized(new Object()) 每次新创建的对象 不是 几乎没有实际加锁价值 每次进来都是新锁,等于没锁住共享资源
synchronized(Xxx.class) Xxx.class 这个 Class 对象 类级别互斥、静态资源保护 和实例锁不是同一把锁
synchronized(lock),其中 lockstatic final Object 这个固定锁对象 显式定义全局共享锁 要保证所有线程拿的是同一个 lock

可以再把它们翻译成人话:

  • this:锁“当前这个对象”
  • new Object():锁“临时现造的一个新对象”
  • Xxx.class:锁“这个类对应的 Class 对象”
  • static final Object lock:锁“大家约定好共用的那把固定锁”

怎么快速判断一把锁到底是不是同一把

判断标准其实只有一个:

多个线程进入这段代码时,拿到的是不是同一个对象引用。

如果是同一个对象,就会互斥;如果不是同一个对象,就不会互斥。

例如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Demo {
    private final Object lock = new Object();

    public void a() {
        synchronized (this) {
        }
    }

    public void b() {
        synchronized (lock) {
        }
    }

    public static void c() {
        synchronized (Demo.class) {
        }
    }
}

这里:

  • a() 锁的是实例对象自己
  • b() 锁的是这个实例里的 lock
  • c() 锁的是 Demo.class

它们默认都不是同一把锁。

所以面试里如果题目问“这几个方法会不会互斥”,第一反应不要先想关键字,而要先问自己:

它们到底是不是在抢同一个对象锁?

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低


synchronized锁升级

synchronized的锁升级,说白了,就是当JVM检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级。 synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁。这里说“锁在对象头里”,并不是说整个锁对象完整塞在对象头中,而是说:JVM 会把和锁状态相关的关键信息记录在对象头的 Mark Word 中,必要时再关联到 Monitor。 得到锁的线程才能访问同步资源


Java对象头中的MarkWord

Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

如果只用一句最通俗的话来理解 Mark Word

它是对象头里最活跃的一块区域,专门拿来放“对象运行时会变的信息”。

比如同一个对象,在不同时间点,Mark Word 里可能表示的是:

  • 还没加锁
  • 已经偏向某个线程
  • 处于轻量级锁
  • 已经膨胀成重量级锁
  • 或者存着对象哈希码等信息

所以对象头不是一直不变的,尤其是 Mark Word,会随着运行时状态变化而变化。

可以把对象头和锁升级串起来理解

1
2
3
4
5
6
7
8
9
10
11
线程还没来竞争
    -> 对象头记录“当前是普通/可偏向状态”

一个线程长期访问
    -> 对象头里的 Mark Word 更偏向这个线程

第二个线程开始竞争
    -> 对象头里的锁标记变化,升级为轻量级锁

竞争进一步加剧
    -> 对象头不再只靠轻量级信息表达,转而关联重量级 Monitor

所以“锁升级”这件事,本质上就是:

JVM 根据竞争情况,不断调整对象头里锁相关信息的表示方式。

一个容易混淆但很重要的点

对象头在Java 堆中的对象内存里,而不是在线程栈里,也不是单独在某个“锁表”里放一份。

但是:

  • 轻量级锁会涉及线程栈中的锁记录(Lock Record)
  • 重量级锁会涉及 Monitor

所以你会看到资料里同时提到“对象头”“栈帧”“Monitor”,它们不是互相矛盾,而是不同锁状态下共同参与同步过程的几部分。

可以先粗略记成:

  • 对象头:锁状态入口,先看这里
  • 线程栈里的锁记录:轻量级锁阶段会用到
  • Monitor:重量级锁阶段会重点用到

锁存储内容.png


升级过程

1、创建一个对象LockObject时,该对象的部分Markword关键数据如下

锁初次创建.png

偏向锁的标志位是“01”,状态是“0”,表示该对象还没有被加上偏向锁。(“1”是表示被加上偏向锁)。该对象被创建出来的那一刻,就有了偏向锁的标志位,这也说明了所有对象都是可偏向的,但所有对象的状态都为“0”,也同时说明所有被创建的对象的偏向锁并没有生效

2、(偏向锁)当线程执行到临界区(critical section)时,此时会利用CAS(Compare and Swap)操作,将线程ID插入到Markword中,同时修改偏向锁的标志位

临界区:就是只允许一个线程进去执行操作的区域,即同步代码块,只要对多线程并发有影响的都叫临界区。CAS是一个原子性操作

偏向锁.png

偏向锁的状态为“1”,说明对象的偏向锁生效了,同时也可以看到那个线程获取了该对象的锁

偏向锁:jdk1.6引入的一项锁优化,其中的“偏”是偏心的偏。它的意思就是说,这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作

3、当出现有两个线程(不同线程id)来竞争锁的话,那么偏向锁就失效了,此时锁就会膨胀,升级为轻量级锁

轻量级锁.png

轻量锁(非阻塞、乐观锁)分为:自旋锁、自适应自旋锁

自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的

锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。 所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能够获得锁了。 经验表明,大部分同步代码块执行的时间都是很短很短的,也正是基于这个原因,才有了轻量级锁这么个东西

基于这个问题,必须给线程空循环设置一个次数,当线程超过了这个次数,就认为使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁 默认情况下,自旋的次数为10次,用户可以通过-XX:PreBlockSpin来进行更改

自适应自旋锁: 所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数。 假如一个线程1刚刚成功获得一个锁,当它把锁释放了之后,线程2获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放该锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程1自旋的次数。 另外,如果对于某一个锁,一个线程自旋之后,很少成功获得该锁,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁的,以免空循环等待浪费资源

4、轻量级锁膨胀之后,就升级为重量级锁

重量级锁.png

当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。 这就是说为什么重量级线程开销很大的


自旋锁

在 Java 中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU


为什么用 Lock、ReadWriteLock

synchronized 的缺陷

  • 被 synchronized 修饰的方法或代码块,只能被一个线程访问。如果这个线程被阻塞,其他线程也只能等待
  • synchronized 不能响应中断
  • synchronized 没有超时机制
  • synchronized 只能是非公平锁

Lock、ReadWriteLock 相较于 synchronized,解决了以上的缺陷:

  • Lock 可以手动释放锁(synchronized 获取锁和释放锁都是自动的),以避免死锁
  • Lock 可以响应中断
  • Lock 可以设置超时时间,避免一致等待
  • Lock 可以选择公平锁或非公平锁两种模式
  • ReadWriteLock 将读写锁分离,从而使读写操作分开,有效提高并发性

Lock 和 ReentrantLock

如果采用 Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用 Lock 必须在 try catch 块中进行,并且将释放锁的操作放在 finally 块中进行,以保证锁一定被被释放,防止死锁的发生

lock() 方法的作用是获取锁。如果锁已被其他线程获取,则进行等待

tryLock() 方法的作用是尝试获取锁,如果成功,则返回 true;如果失败(即锁已被其他线程获取),则返回 false。也就是说,这个方法无论如何都会立即返回,获取不到锁时不会一直等待

tryLock(long time, TimeUnit unit) 方法和 tryLock() 方法是类似的,区别仅在于这个方法在获取不到锁时会等待一定的时间,在时间期限之内如果还获取不到锁,就返回 false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回 true

lockInterruptibly() 方法比较特殊,当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态

也就使说,当两个线程同时通过 lock.lockInterruptibly() 想获取某个锁时,假若此时线程 A 获取到了锁,而线程 B 只有在等待

那么对线程 B 调用 threadB.interrupt() 方法能够中断线程 B 的等待过程。由于 lockInterruptibly() 的声明中抛出了异常,所以 lock.lockInterruptibly() 必须放在 try 块中或者在调用 lockInterruptibly() 的方法外声明抛出 InterruptedException

注意:当一个线程获取了锁之后,是不会被 interrupt() 方法中断的。因为本身在前面的文章中讲过单独调用 interrupt() 方法不能中断正在运行过程中的线程,只能中断阻塞过程中的线程。因此当通过 lockInterruptibly() 方法获取某个锁时,如果不能获取到,只有进行等待的情况下,是可以响应中断的

unlock() 方法的作用是释放锁

ReentrantLock 是唯一实现了 Lock 接口的类

ReentrantLock 字面意为可重入锁


ReadWriteLock 和 ReentrantReadWriteLock

对于特定的资源,ReadWriteLock 允许多个线程同时对其执行读操作,但是只允许一个线程对其执行写操作。

ReadWriteLock 维护一对相关的锁。一个是读锁;一个是写锁。将读写锁分开,有利于提高并发效率。

ReentrantReadWriteLock 实现了 ReadWriteLock 接口,所以它是一个读写锁。

“读-读”线程之间不存在互斥关系。

“读-写”线程、“写-写”线程之间存在互斥关系


Synchronized和lock区别

synchronized和lock的用法区别

synchronized:在需要同步的对象中加入此控制,synchronized可以加在方法上,也可以加在特定代码块中,括号中表示需要锁的对象

lock:需要显示指定起始位置和终止位置。一般使用ReentrantLock类做为锁,多个线程中必须要使用一个ReentrantLock类做为对象才能保证锁的生效。且在加锁和解锁处需要通过lock()和unlock()显示指出。所以一般会在finally块中写unlock()以防死锁

synchronized和lock性能区别

synchronized是托管给JVM执行的

而lock是java写的控制锁的代码类

在Java1.5中,synchronize是性能低效的。因为这是一个重量级操作,需要调用操作接口,导致有可能加锁消耗的系统时间比加锁以外的操作还多

相比之下使用Java提供的Lock对象,性能更高一些。但是到了Java1.6,发生了变化

synchronize在语义上很清晰,可以进行很多优化,有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在Java1.6上synchronize的性能并不比Lock差。官方也表示,他们也更支持synchronize,在未来的版本中还有优化余地

synchronized原始采用的是CPU悲观锁机制,即线程获得的是独占锁。独占锁意味着其他线程只能依靠阻塞来等待线程释放锁。而在CPU转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起CPU频繁的上下文切换导致效率很低

Lock用的是乐观锁方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁实现的机制就是CAS操作(Compare and Swap)。我们可以进一步研究ReentrantLock的源代码,会发现其中比较重要的获得锁的一个方法是compareAndSetState。这里其实就是调用的CPU提供的特殊指令

现代的CPU提供了指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。这个算法称作非阻塞算法,意思是一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法

synchronized和lock用途区别

synchronized原语和ReentrantLock在一般情况下没有什么区别,但是在非常复杂的同步应用中,请考虑使用ReentrantLock,特别是遇到下面几种需求的时候

  • 某个线程在等待一个锁的控制权的这段时间需要中断
  • 需要分开处理一些wait-notify,ReentrantLock里面的Condition应用,能够控制notify哪个线程
  • 具有公平锁功能,每个到来的线程都将排队等候
类别 synchronized Lock
存在层次 Java的关键字,在jvm层面上 是一个类
锁的释放 1、以获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁 在finally中必须释放锁,不然容易造成线程死锁
锁的获取 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 分情况而定,Lock有多个锁获取的方式,大致就是可以尝试获得锁,线程可以不用一直等待
锁状态 无法判断 可以判断
锁类型 可重入 不可中断 非公平 可重入 可判断 可公平(两者皆可)
性能 少量同步 大量同步