Java:并发
1 进程和线程
进程具有两个特点:
- 资源所有权:进程具有对资源的控制权和所有权,这些资源包括内存、I/O通道、I/O设备和文件等。
- 调度/执行:进程时可被操作系统调度和分派的实体
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
将分派的单位称为线程或轻量级进程,线程是程序执行的最小单位,同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈。
1.1 用户线程、内核线程
-
用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。
-
内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。
-
用户线程:创建和切换成本低,不可以利用多核。内核一次把一个进程分配给一个处理器,一个进程中只有一个线程可以执行
-
内核线程:创建和切换成本高,需要从用户模式切换到内核模式,再切换回用户模式。可以利用多核。
用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。
1.2 虚拟线程
虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。 许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。
在引入虚拟线程之前,java.lang.Thread
包已经支持所谓的平台线程,也就是没有虚拟线程之前,我们一直使用的线程。JVM 调度程序通过平台线程
(载体线程)来管理虚拟线程
,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。
关于平台线程和系统内核线程的对应关系多提一点:在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个平台线程对应一个系统内核线程。
相比较于平台线程来说,虚拟线程是廉价且轻量级的,使用完后立即被销毁,因此它们不需要被重用或池化,每个任务可以有自己专属的虚拟线程来运行。虚拟线程暂停和恢复来实现线程之间的切换,避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂,可以有效减少编写、维护和观察高吞吐量并发应用程序的工作量。
2 线程
2.1 线程状态和生命周期
-
NEW
: 新建状态,线程被创建出来但没有被调用start()
。 -
RUNNABLE
: 可运行状态,线程被调用了start()
等待运行的状态。 -
BLOCKED
:阻塞状态,需要等待锁释放。 -
WAITING
:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。 -
TIME_WAITING
:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。 -
TERMINATED
:终止状态,表示该线程已经运行完毕。
生命周期:程序的执行过程再不同状态之间切换
2.1.1 被阻塞线程和等待线程
-
当一个线程试图获取一个
内部的对象锁
(而不是java.util.concurrent 库中的锁),而该锁被其他线程持有, 则该线程进人阻塞状态。当所有其他线程释放该锁,并且线程调度器允许本线程持有它的时候,该线程将变成非阻塞状态。 -
当线程等待另一个线程通知调度器一个条件时, 它自己进入等待状态。在调用Object.wait 方法或
Thread.join
方法, 或者是等待java.util.concurrent 库中的Lock 或Condition 时, 就会出现这种情况。实际上,被阻塞状态与等待状态是有很大不同的。 -
有几个方法有一个超时参数。调用它们导致线程进人计时等待( timed waiting ) 状态。这一状态将一直保持到超时期满或者接收到适当的通知。带有超时参数的方法有Thread.sleep 和Object.wait、Thread.join、Lock,tryLock 以及Condition.await 的计时版。
void join(); //等待终止指定的线程 |
2.2 线程上下文切换
线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
-
主动让出 CPU,比如调用了
sleep()
,wait()
等。 -
时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
-
调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
-
被终止或结束运行
2.3 创建线程
继承
Thread
类、实现Runnable
接口、实现Callable
接口、使用线程池、使用CompletableFuture
类等
2.3.1 继承Thread类
用户自定义类继承java.lang.Thread类,重写其run()方法,run()方法中定义了线程执行的具体任务。创建该类的实例后,通过调用start()方法启动线程。
class MyThread extends Thread { |
2.3.2 实现Runnable接口
如果一个类已经继承了其他类,就不能再继承Thread类,此时可以实现java.lang.Runnable接口。实现Runnable接口需要重写run()方法,然后将此Runnable对象作为参数传递给Thread类的构造器,创建Thread对象后调用其start()方法启动线程。
class MyRunnable implements Runnable { |
2.3.3 实现Callable接口与FutureTask
java.util.concurrent.Callable接口类似于Runnable,但Callable的call()方法可以有返回值并且可以抛出异常。要执行Callable任务,需将它包装进一个FutureTask,因为Thread类的构造器只接受Runnable参数,而FutureTask实现了Runnable接口。
class MyCallable implements Callable<Integer> { |
2.3.4 使用线程池(Executor框架)
从Java 5开始引入的java.util.concurrent.ExecutorService和相关类提供了线程池的支持,这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销。可以通过Executors类的静态方法创建不同类型的线程池。
class Task implements Runnable { |
[!note] 可以直接调用 Thread 类的 run 方法吗?直接调用run方法,只会执行通过一个线程中的任务,而不会启动新线程。应该调用Thread.start方法,这个方法将创建一个执行run方法的新线程。
new 一个Thread
,线程进入了新建状态。调用start()
方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。start()
会执行线程的相应准备工作,然后自动执行run()
方法的内容,这是真正的多线程工作。 但是,直接执行run()
方法,会把run()
方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
[! question] Thread#sleep() 方法和 Object#wait() 方法对比
sleep()
方法没有释放锁,而wait()
方法释放了锁 。wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒,或者也可以使用wait(long timeout)
超时后线程会自动苏醒。sleep()
是Thread
类的静态本地方法,wait()
则是Object
类的本地方法。为什么这样设计呢?下一个问题就会聊到
3 多线程
3.1 并发、并行
-
并发:两个及两个以上的作业在同一 时间段 内执行。
-
并行:两个及两个以上的作业在同一 时刻 执行。
3.2 同步、异步
-
同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
-
异步:调用在发出之后,不用等待返回结果,该调用直接返回。
-
线程同步:多个线程合作,线程的执行需要满足某种时序关系
3.3 线程安全和不安全
线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。
-
线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
-
线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。
-
线程安全:
- 线程安全指的是在多线程环境下,对共享数据的访问操作能够保证在并发情况下不会导致数据的不一致性或损坏。一个线程安全的操作或数据结构能够在并发访问时维持其内部状态的一致性。
- 线程安全的实现通常会采用同步机制(例如锁、信号量等)来保护共享资源的访问,以确保在任意时刻只有一个线程能够访问共享资源,从而避免竞态条件(Race Condition)和其他并发问题。
-
线程不安全:
- 线程不安全指的是在多线程环境下,对共享数据的访问操作可能会导致数据的不一致性或损坏。线程不安全的操作或数据结构在并发访问时无法保证其内部状态的一致性,可能会导致意外的结果或程序错误。
- 线程不安全的实现通常没有考虑到并发访问的情况,没有采取适当的同步措施来保护共享资源的访问,因此可能会出现竞态条件和其他并发问题。
举例来说,如果多个线程同时尝试向同一个数组中添加元素,而该数组的添加操作没有进行适当的同步控制,那么就可能导致线程不安全的情况,如数据覆盖、越界访问等。为了保证线程安全,需要在并发访问共享资源时使用适当的同步机制来确保数据的一致性。
3.4 线程安全
保证线程安全,避免数据竞争造成数据混乱的问题
Java的线程安全体现在三个方面:
-
原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic和synchronized这两个关键字来确保原子性;
-
可见性:一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性;
-
有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens-before原则来确保有序性。
3.5 保证数据一致性方案
-
事务管理:使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过ACID(原子性、一致性、隔离性、持久性)属性,数据库事务可以保证数据的一致性。
-
锁机制:使用锁来实现对共享资源的互斥访问。在 Java 中,可以使用 synchronized 关键字、ReentrantLock 或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。
-
版本控制:通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性。
3.6 线程死锁
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
[!question] 检测死锁使用
jmap
、jstack
等命令查看 JVM 线程栈和堆内存的情况。如果有死锁,jstack
的输出中通常会有Found one Java-level deadlock:
的字样,后面会跟着死锁相关的线程信息。另外,实际项目中还可以搭配使用top
、df
、free
等命令查看操作系统的基本情况,出现死锁可能会导致 CPU、内存等资源消耗过高。
[! question] 如何预防和避免死锁?
-
死锁预防
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
-
死锁避免:在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
-
死锁检测
4 线程同步
原子性:一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。可见性:当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。有序性:由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。
4.1 volatile(可见性、有序性)
volatile关键字用于变量,确保所有线程看到的是该变量的最新值,而不是可能存储在本地寄存器中的副本。
可见性、不保证原子性、防止JVM的指令重排序
适用于一写多读
volatile 关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为volatile ,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。保证变量的可见性。当一个变量被声明成volatile,JMM会确保所有线程看到这个变量的值是一致的。
volatile禁用CPU缓存,表示这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile int inc=0; |
4.1.1 可见性
-
保证变量对所有线程的可见性。当一个变量被声明为volatile时,它会保证对这个变量的写操作会立即刷新到主存中,而对这个变量的读操作会直接从主存中读取,从而确保了多线程环境下对该变量访问的可见性。这意味着一个线程修改了volatile变量的值,其他线程能够立刻看到这个修改,不会受到各自线程工作内存的影响。
- 1️⃣在生成最低成汇编指令时,对volatile修饰的共享变量写操作增加Lock前缀指令,Lock 前缀的指令会引起 CPU 缓存写回内存;
- 2️⃣CPU 的缓存回写到内存会导致其他 CPU 缓存了该内存地址的数据无效;
- 3️⃣volatile 变量通过缓存一致性协议保证每个线程获得最新值;
- 4️⃣缓存一致性协议保证每个 CPU 通过嗅探在总线上传播的数据来检查自己缓存的值是不是修改;
- 5️⃣当 CPU 发现自己缓存行对应的内存地址被修改,会将当前 CPU 的缓存行设置成无效状态,重新从内存中把数据读到 CPU 缓存。
1. 缓存一致性协议:
• 在多核处理器系统中,每个处理器核都有自己的缓存。当一个线程修改了一个volatile
变量,这个修改会立即被写回到主内存,并且通过缓存一致性协议(如MESI协议)使其他处理器核中的缓存无效(或者更新)。
• 当其他线程读取这个volatile
变量时,它们会从主内存中读取最新的值,而不是从缓存中读取过时的值。
2. 编译器优化禁止:
- 编译器不会对volatile
变量进行某些优化,比如将变量值存储在寄存器中,而不是从内存中读取。这确保了每次访问volatile
变量都会直接从内存中读取。
4.1.2 有序性
-
禁止指令重排序优化。volatile关键字在Java中主要通过内存屏障来禁止特定类型的指令重排序。
-
1)写-写(Write-Write)屏障:在对volatile变量执行写操作之前,会插入一个写屏障。这确保了在该变量写操作之前的所有普通写操作都已完成,防止了这些写操作被移到volatile写操作之后。
-
2)读-写(Read-Write)屏障:在对volatile变量执行读操作之后,会插入一个读屏障。它确保了对volatile变量的读操作之后的所有普通读操作都不会被提前到volatile读之前执行,保证了读取到的数据是最新的。
-
3)写-读(Write-Read)屏障:这是最重要的一个屏障,它发生在volatile写之后和volatile读之前。这个屏障确保了volatile写操作之前的所有内存操作(包括写操作)都不会被重排序到volatile读之后,同时也确保了volatile读操作之后的所有内存操作(包括读操作)都不会被重排序到volatile写之前。
-
1. 插入内存屏障:
• 当JVM编译含有volatile
变量的代码时,会在读写volatile
变量前后插入内存屏障(Memory Barrier/Fence)。
• 这些内存屏障会防止JVM和处理器对这些操作进行重排序,确保在volatile
变量读写操作之前的所有操作都已经完成,并且之后的操作不会提前进行。
2. ** happens-before 原则**:
• Java内存模型定义了happens-before
原则,其中有一条规则是:对一个volatile
变量的写操作对后续对这个变量的读操作是可见的。
• 这意味着,如果你有一个线程写入了volatile
变量,然后另一个线程读取了这个变量,那么读取操作保证能看到写入操作的结果,且读取操作之前的所有写操作也都对读取线程可见。
volatile可见性实现原理_volatile是怎么实现可见性的?-CSDN博客
[# volatile关键字,他是如何保证可见性,有序性?](volatile关键字,他是如何保证可见性,有序性? (qq.com))
4.2 锁
4.2.1 乐观锁和悲观锁
乐观锁
:线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。悲观锁
:每次获取资源都会上锁,只有持有者释放锁其他线程才可以访问共享资源。Java 中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。乐观锁主要针对的对象是单个共享变量。悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。
对比
:高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
-
悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能
-
乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。
4.2.2 乐观锁实现
版本号机制
一般是在数据表中加上一个数据版本号 version
字段,表示数据被修改的次数。当数据被修改时,version
值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version
值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version
值相等时才更新,否则重试更新操作,直到更新成功。
CAS(Compare And Swap)
专用机器指令,CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
CAS 涉及到三个操作数:
-
V:要更新的变量值(Var)
-
E:预期值(Expected)
-
N:拟写入的新值(New)
当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。
sun.misc
包下的Unsafe
类提供了compareAndSwapObject
、compareAndSwapInt
、compareAndSwapLong
方法来实现的对Object
、int
、long
类型的 CAS 操作
CAS存在问题
ABA
如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,但是在这期间可能被修改为B值。解决:在变量前追加版本号和时间戳
循环时间长开销大
CAS 经常会用到自旋操作
来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了AtomicReference
类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference
类把多个共享变量合并成一个共享变量来操作。
4.2.3 公平锁、非公平锁
-
公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
-
非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
4.2.4 可中断锁、不可中断锁
-
可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。
ReentrantLock
就属于是可中断锁。 -
不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。
synchronized
就属于是不可中断锁。
可见性 | 原子性 | 可重入 | 公平锁、非公平锁 | 可中断锁、不可中断 | |
---|---|---|---|---|---|
volatile | 是 | ||||
synchronized | 是 | 是 | 是 | 非公平锁 | |
ReentrantLock | 是 | 公平锁、非公平锁 | 可中断锁 |
4.2.5 共享锁、独占锁
-
共享锁:一把锁可以被多个线程同时获得。
-
独占锁:一把锁只能被一个线程获得。
4.2.6 synchronized(互斥性、可见性、有序性)
对象锁是通过synchronized
关键字锁定对象的监视器(monitor)来实现的。
保证数据可见性、保证数据原子性
每一个对象有一个内部锁,并且该锁有一个内部条件。由锁来管理那些试图进入synchronized 方法的线程, 由条件来管理那些调用wait 的线程。主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
void notifyAll(); |
-
修饰实例方法(获取对象实例锁)
-
修饰静态方法(获取类的锁)
-
修饰代码块(锁指定对象/类)
synchronized(object)
表示进入同步代码库前要获得 给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 给定 Class 的锁
构造方法不能使用 synchronized 关键字修饰。
构造方法本身就属于线程安全的,不存在同步的构造方法一说。
[!note] 静态
synchronized
方法和非静态synchronized
方法之间的调用互斥么?
不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized
方法占用的锁是当前类的锁,而访问非静态 synchronized
方法占用的锁是当前实例对象锁。
synchronized底层原理
Java中的每一个对象有一个内部的锁和内部的条件。如果一个方法用synchronized关键字声明,那么,它表现的就像是一个监视器方法。通过调用wait/notifyAll/notify来访问条件变量
synchronized同步语句块
synchronized
同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。当执行 monitorenter
指令时,线程试图获取锁也就是获取 对象监视器 monitor
的持有权。
在执行monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
实例方法、静态方法如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。
synchronized是Java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁, 使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。
JDK1.6 之后的 synchronized 底层做了哪些优化?锁升级原理了解吗?
在jdk1.5(包含)版本之前,因为加锁和释放锁的过程JVM的底层都是由操作系统mutex lock来实现的,其中会涉及上下文的切换(即用户态和内核态的转换),性能消耗极其高,所以在当时synchronized锁是公认的重量级锁。
在 Java 6 之后, synchronized
引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized
锁的效率提升了很多(JDK18 中,偏向锁已经被彻底废弃,前面已经提到过了)。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
浅析synchronized锁升级的原理与实现 - 小新成长之路 - 博客园 (cnblogs.com)
[!question] synchronized锁升级过程
sychronized的自旋锁、偏向锁、轻量级锁、重量级锁,分别介绍和联系 (qq.com)
-
偏向锁:这个是在偏向锁开启之后的锁的状态,如果还没有一个线程拿到这个锁的话,这个状态叫做匿名偏向,当一个线程拿到偏向锁的时候,下次想要竞争锁只需要拿线程ID跟MarkWord当中存储的线程ID进行比较,如果线程ID相同则直接获取锁(相当于锁偏向于这个线程),不需要进行CAS操作和将线程挂起的操作。
-
轻量级锁:在这个状态下线程主要是通过CAS操作实现的。将对象的MarkWord存储到线程的虚拟机栈上,然后通过CAS将对象的MarkWord的内容设置为指向Displaced Mark Word的指针,如果设置成功则获取锁。在线程出临界区的时候,也需要使用CAS,如果使用CAS替换成功则同步成功,如果失败表示有其他线程在获取锁,那么就需要在释放锁之后将被挂起的线程唤醒。
-
重量级锁:当有两个以上的线程获取锁的时候轻量级锁就会升级为重量级锁,因为CAS如果没有成功的话始终都在自旋,进行while循环操作,这是非常消耗CPU的,但是在升级为重量级锁之后,线程会被操作系统调度然后挂起,这可以节约CPU资源。(用户态到内核态切换)
synchronized 和 volatile
-
volatile
关键字是线程同步的轻量级实现,所以volatile
性能肯定比synchronized
关键字要好 。但是volatile
关键字只能用于变量而synchronized
关键字可以修饰方法以及代码块 。(原子性) -
volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证,原子性和可见性。 -
volatile
关键字主要用于解决变量在多个线程之间的可见性,而synchronized
关键字解决的是多个线程之间访问资源的同步性。
syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。
4.2.7 ReentrantLock(可重入锁)
可重入、独占式锁、可中断锁
ReentrantLock
实现了 Lock
接口,是一个可重入且独占式的锁,和 synchronized
关键字类似。不过,ReentrantLock
更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
ReentrantLock( ) 构建一个可以被用来保护临界区的可重入锁。 |
synchronized 和 ReentrantLock
两者都是可重入锁
可重入锁也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
synchronized
是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized
关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
ReentrantLock
是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
ReentrantLock增加一些高级功能
-
等待可中断 :
ReentrantLock
提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()
来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。 -
可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
ReentrantLock
默认情况是非公平的,可以通过ReentrantLock
类的ReentrantLock(boolean fair)
构造方法来指定是否是公平的。 -
可实现选择性通知(锁可以绑定多个条件):
synchronized
关键字与wait()
和notify()
/notifyAll()
方法相结合可以实现等待/通知机制。ReentrantLock
类当然也可以实现,但是需要借助于Condition
接口与newCondition()
方法。
4.3 条件变量
线程进入临界区,但需要某一条件满足才可以执行。通过条件变量管理获得一个锁但不能做有用工作的线程。
//Condition cond; |
mu.Lock() |
4.4 Semaphore(信号量)
synchronized
和 ReentrantLock
都是一次只允许一个线程访问某个资源,而Semaphore
(信号量)可以用来控制同时访问特定资源的线程数量。
Semaphore
是共享锁的一种实现,它默认构造 AQS 的 state
值为 permits
,你可以将 permits
的值理解为许可证的数量,只有拿到许可证的线程才能执行。
调用semaphore.acquire()
,线程尝试获取许可证,如果 state >= 0
的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state
的值 state=state-1
。如果 state<0
的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。
4.5 CountDownLatch
CountDownLatch
允许count
个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
CountDownLatch
是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当CountDownLatch
使用完毕后,它不能再次被使用。
CountDownLatch
是共享锁的一种实现,它默认构造 AQS 的 state
值为 count
。当线程使用 countDown()
方法时,其实使用了tryReleaseShared
方法以 CAS 的操作来减少 state
,直至 state
为 0 。当调用 await()
方法的时候,如果 state
不为 0,那就证明任务还没有执行完毕,await()
方法就会一直阻塞,也就是说 await()
方法之后的语句不会被执行。直到count
个线程调用了countDown()
使 state 值被减为 0,或者调用await()
的线程被中断,该线程才会从阻塞中被唤醒,await()
方法之后的语句得到执行。
4.6 CyclicBarrier
CyclicBarrier
和 CountDownLatch
非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch
更加复杂和强大。主要应用场景和 CountDownLatch
类似。
CountDownLatch
的实现是基于 AQS 的,而CycliBarrier
是基于ReentrantLock
(ReentrantLock
也属于 AQS 同步器)和Condition
的。
CyclicBarrier
的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
CyclicBarrier
内部通过一个 count
变量作为计数器,count
的初始值为 parties
属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
5 Future、Runnable、Callable
5.1 Runnable、Callable
Runnable
接口不会返回结果或抛出检查异常,但是 Callable
接口可以。工具类 Executors
可以实现将 Runnable
对象转换成 Callable
对象。(Executors.callable(Runnable task)
或 Executors.callable(Runnable task, Object result)
)。
Runnable.java
@FunctionalInterface |
Callable.java
|
6 Java并发容器
-
ConcurrentHashMap
: 线程安全的HashMap
-
CopyOnWriteArrayList
: 线程安全的List
,在读多写少的场合性能非常好,远远好于Vector
。 -
ConcurrentLinkedQueue
: 高效的并发队列,使用链表实现。可以看做一个线程安全的LinkedList
,这是一个非阻塞队列。 -
BlockingQueue
: 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。 -
ConcurrentSkipListMap
: 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。
6.1 BlockingQueue
-
ArrayBlockingQueue
-
LinkedBlockingQueue
-
PriorityBlockingQueue
7 总结
-
线程有几种状态
-
synchronized的锁升级
-
CAS实现