Java并发的学习
Java 并发基础
一.什么是线程和进程
1.1. 何为进程?
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
任务管理器。。看到window操作系统正在运行的进程
1.2. 何为线程?
- 线程是一个比进程更小的执行单位。
- 一个进程在其执行的过程中可以产生多个线程。
- 与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
- 一个 Java 程序的运行是 main 线程和多个其他线程同时运行。
二.线程与进程之间的区别,关系,优缺点
1.线程与进程之间的区别
-
1)根本区别:进程是资源分配最小单位,线程是程序执行的最小单位;(资源调度的最小单位)
-
2)内存分配:进程有自己独立的地址空间,每启动一个进程,系统都会为其分配地址空间,建立数据表来维护代码段、堆栈段和数据段,线程没有独立的地址空间,它使用相同的地址空间共享数据;
所以
- CPU切换一个线程比切换进程花费小;
- 创建一个线程比进程开销小;
-
3)资源开销:线程占用的资源要⽐进程少很多。
进程对资源保护要求高,开销大,效率相对较低,线程资源保护要求不高,但开销小,效率高,可频繁切换;
-
4)通信方式:线程之间通信更方便,同一个进程下,线程共享全局变量,静态变量等数据
而进程之间互不干扰,相互独立,进程的通信机制相对很复杂,譬如管道,信号,消息队列,共享内存,套接字等通信机制
-
5)包含关系:线程是进程的一部分,一个进程内可以有多个线程
-
6)影响关系:多进程程序更安全,生命力更强,一个进程死掉不会对另一个进程造成影响(源于有独立的地址空间),多线程程序更不易维护,一个线程死掉,整个进程就死掉了
- 因为当一个线程死了(非正常退出、死循环等)就会导致线程该占有的资源永远无法释放,从而影响其他线程的正常工作
2.线程与进程之间的关系
- 一个进程中可以有多个线程
- 多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)*资源
- 但是每个线程有自己的**程序计数器、虚拟机栈 和 本地方法栈。
总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
3.程序计数器为什么是私有的?
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。(所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。)
- 需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
4.虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈:每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈:和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
- 所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的
5.为什么要使用多线程?
-
1)从当代互联网发展趋势来说: 多线程并发编程(目的就是为了能提高程序的执行效率提高程序运行速度)正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
-
2)从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。
-
在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。如CPU既可以进行计算,又可以进行IO操作
-
在多核 CPU 时代多线程主要是为了提高 CPU 利用率,一个CPU有多个CPU核心,这也意味着多个线程可以同时运行,从而被CPU核心利用到,这减少了线程上下文切换的开销。
6.使用多线程会出现的问题
- 内存泄漏:也称作"存储渗漏",用动态存储分配函数动态开辟的空间,在使用完毕后未释放,结果导致一直占据该内存单元。直到程序结束。(其实说白了就是该内存空间使用完毕之后未回收)即所谓内存泄漏。
- 死锁
- 线程不安全
6.1 如何理解线程安全
- 多个线程访问一个同一段代码,如果每次运行的结果和单线程运行的结果一致,其他变量的值也和预期值相符,就是线程安全的
6.2 java如何实现线程安全
-
线程安全需要保证多个线程访问数据的一致性,避免关键资源使用冲突,结果不一致等问题,所以要使用线程同步的方式,实现并发编程
-
从线程安全,实现并发编程关注的三个特点说起的话:
-
原子性 : 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。
synchronized
关键字以及 各种 Lock 机制都可以保证代码片段的原子性。synchronized
关键字以及 各种 Lock 机制区别见后面 -
可见性 :当一个变量对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
volatile
关键字可以保证共享变量的可见性。 -
有序性 :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。
volatile
关键字可以禁止指令进行重排序优化。(使用volatile
可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。)
-
三.线程的生命周期
1.线程生命周期的6个状态
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态
2.线程生命周期状态的迁移
-
由上图可以看出:线程创建之后它将处于 NEW(新建) 状态,调用
start()
方法后开始运行,线程这时候处于 READY(就绪) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。- 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?(见三.3)
-
当线程执行
wait()
方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 TIME_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过sleep(long millis)
方法或wait(long millis)
方法可以将 Java 线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。- sleep() 方法和 wait() 方法区别和共同点(见三.4)
-
当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞) 状态。线程在执行 Runnable 的
run()
方法之后将会进入到 TERMINATED(终止) 状态。 -
在操作系统中:
操作系统隐藏 Java 虚拟机(JVM)中的 READY 和 RUNNING 状态,它只能看到 RUNNABLE 状态(图源:HowToDoInJava:Java Thread Life Cycle and Thread States),所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。
3. 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
面试题
- new 一个 Thread,线程将进入NEW(新建)状态。调用
start()
方法,会启动一个线程并使线程进入了READY(就绪) 状态,可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态,就可以开始运行了,也就是自动开始执行run()方法。start()
会执行线程的相应准备工作,然后自动执行run()
方法的内容,这是真正的多线程工作。 - 但是,直接执行
run()
方法,会把run()
方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start()
方法方可启动线程并使线程进入就绪状态,直接执行 run()
方法的话不会以多线程的方式执行。
4.sleep() 方法和 wait() 方法区别和共同点
- 两者最主要的区别在于:
sleep()
方法没有释放锁,而wait()
方法释放了锁 。 - 两者都可以暂停线程的执行。
wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)
超时后线程会自动苏醒。
5.终止线程的方法
- 使用标志位退出线程:定义一个boolean型的标志位,在线程的run方法中根据这个标志位是true还是false来判断是否退出
- 使用stop方法强制终止线程:stop方法是很危险的,就象突然关闭计算机电源,而不是按正常程序关机一样,可能会产生不可预料的结果,因此,并不推荐使用stop方法来终止线程。
- 使用interrupt方法终止线程
- (1)线程处于阻塞状态,如使用了sleep方法。
- (2)使用while(!isInterrupted()){……}来判断线程是否被中断。
在第一种情况下使用interrupt方法,sleep方法将抛出一个InterruptedException例外,而在第二种情况下线程将直接退出。
6.理解上下文切换
- 多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
- 概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
- 注意:上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作
- Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
四.线程死锁
1.线程死锁的现象描述
-
线程死锁描述的是这样一种情况:多个线程竞争有限的资源,它们中的一个或者全部都在等待某个资源被释放,而同时进入阻塞状态。由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
2.产生死锁必要的条件
- 1)互斥条件:该资源任意一个时刻只由一个线程占用。
- 2)请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 3)不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 4)循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
3.如何避免线程死锁?
对产生死锁的四个必要条件进行分析,为了避免死锁,我们只要破坏产生死锁的四个条件中的其中一个就可以了。现在我们来挨个分析一下:
- 破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
- 破坏请求与保持条件 :一次性申请所有的资源。
- 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
五.synchronized 关键字
1.synchronized 关键字的发展
synchronized
关键字解决的是多个线程之间访问资源的同步性,synchronized
关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。- 在 Java 早期版本中,
synchronized
属于 重量级锁,效率低下。- 因为监视器锁(monitor)是依赖于底层的操作系统的
Mutex Lock
来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
- 因为监视器锁(monitor)是依赖于底层的操作系统的
- 庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对
synchronized
较大优化,所以现在的synchronized
锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
2.synchronized 关键字的使用
1)修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
synchronized void method() {
//业务代码
}
2)修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized
方法占用的锁是当前类的锁,而访问非静态 synchronized
方法占用的锁是当前实例对象锁。
synchronized static void method() {
//业务代码
}
3)修饰代码块 :指定加锁对象,对给定对象/类加锁。synchronized(this|object)
表示进入同步代码库前要获得给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 当前 class 的锁
synchronized(this) {
//业务代码
}
总结:
synchronized
关键字加到static
静态方法和synchronized(class)
代码块上都是是给 Class 类上锁。synchronized
关键字加到实例方法上是给对象实例上锁。- 尽量不要使用
synchronized(String a)
因为 JVM 中,字符串常量池具有缓存功能!
3.双重校验锁实现对象单例(线程安全)
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
-
uniqueInstance
采用volatile
关键字修饰,volatile 关键字 是线程同步的 轻量级实现 -
使用
volatile
可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。 -
uniqueInstance = new Singleton();
这段代码其实是分为三步执行:-
1)为
uniqueInstance
分配内存空间(给新创建出来Singleton的实例在堆中分配内存空间) -
2)初始化
uniqueInstance
(调用构造函数初始化这个新创建出来Singleton的实例)构造方法不需要用synchronized 关键字修饰,构造方法本身就属于线程安全的。
-
3)将
uniqueInstance
指向分配的内存地址
-
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 线程T2 调用 getUniqueInstance
() 后发现 uniqueInstance
不为空,因此返回 uniqueInstance
,但此时 uniqueInstance
还未被初始化。
4.synchronized 关键字的底层原理
synchronized 关键字底层原理属于 JVM 层面。
4.1 synchronized 修饰代码块来同步的情况
synchronized(this) {
//业务代码
}
-
synchronized
同步语句块的实现使用的是monitorenter
和monitorexit
指令,其中monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。 -
在执行
monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。 -
在执行
monitorexit
指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
4.2 synchronized
修饰方法来同步的情况
synchronized
修饰的方法并没有monitorenter
指令和monitorexit
指令,取得代之的确实是ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。- JVM 通过该
ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
以上两种的本质都是对对象监视器 monitor 的获取。
5. JDK1.6之后synchronized 关键字底层做了哪些优化
- 对锁的实现引入:偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销
5.1优化后synchronized锁的分类
级别从低到高依次是:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
锁可以升级, 但不能降级. 即: 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁是单向的.
5.2 偏向锁与轻量级锁
-
偏向锁是针对于一个线程而言的, 线程获得锁之后就不会再有解锁等操作了, 这样可以省略很多开销.
-
假如有两个线程来竞争该锁话, 那么偏向锁就失效了, 进而升级成轻量级锁了
-
偏向锁的加锁
当一个线程访问同步块并获取锁时, 会在锁对象的对象头和在 线程栈帧中的锁记录里存储锁偏向的线程ID, 以后该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁, 只需要简单的测试一下锁对象的对象头的MarkWord里是否存储着指向当前线程的偏向锁(线程ID是当前线程)
-
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制, 所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁
-
轻量级锁加锁
线程在执行同步块之前, JVM会先在当前线程的栈帧中创建用户存储锁记录的空间, 并将对象头中的MarkWord复制到锁记录空间中. 然后线程尝试使用CAS将对象头中的MarkWord替换为指向锁记录空间的指针. 如果成功, 当前线程获得锁; 如果失败, 表示其它线程竞争锁, 当前线程便尝试使用自旋来获取锁, 之后再来的线程, 发现是轻量级锁, 就开始进行自旋.
-
轻量级锁解锁
轻量级锁解锁时, 会使用原子的CAS操作将当前线程的锁记录空间替换回到对象头, 如果成功, 表示没有竞争发生; 如果失败, 表示当前锁存在竞争, 锁就会膨胀成重量级锁.
-
5.3.锁的比较
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗, 和执行非同步代码方法的性能相差无几. | 如果线程间存在锁竞争, 会带来额外的锁撤销的消耗. | 适用于只有一个线程访问的同步场景 |
轻量级锁 | 竞争的线程不会阻塞, 提高了程序的响应速度 | 如果始终得不到锁竞争的线程, 使用自旋会消耗CPU | 追求响应时间, 同步快执行速度非常快 |
重量级锁 | 线程竞争不适用自旋, 不会消耗CPU | 线程堵塞, 响应时间缓慢 | 追求吞吐量, 同步快执行时间速度较长 |
5.4 Java对象头(存储锁类型)
- 在HotSpot虚拟机中, 对象在内存中的布局分为三块区域: 对象头, 示例数据和对其填充.
- 对象头中包含两部分: MarkWord 和 类型指针.
- Mark Word用于存储对象自身的运行时数据, 如HashCode, GC分代年龄, 锁状态标志, 线程持有的锁, 偏向线程ID等等.
- 对象头中包含两部分: MarkWord 和 类型指针.
如果是数组对象的话, 对象头还有一部分是存储数组的长度.
多线程下synchronized的加锁就是对同一个对象的对象头中的MarkWord中的变量进行CAS操作.
6.谈谈 synchronized 和 ReentrantLock 的区别
6.1 两者都是可重入锁
“可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。
6.2 synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
synchronized
是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized
关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock
是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
6.3 ReentrantLock 比 synchronized 增加了一些高级功能
- 它们都有超时等待(获取锁)
相比synchronized
,ReentrantLock
增加了一些高级功能。主要来说主要有三点:
- 等待可中断(获取锁) :
ReentrantLock
提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()
来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。 - 可实现公平锁 :
ReentrantLock
可以指定是公平锁还是非公平锁。而synchronized
只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock
默认情况是非公平的,可以通过ReentrantLock
类的ReentrantLock(boolean fair)
构造方法来制定是否是公平的。 - 可实现选择性通知(锁可以绑定多个条件):
synchronized
关键字与wait()
和notify()
/notifyAll()
方法相结合可以实现等待/通知机制。ReentrantLock
类当然也可以实现,但是需要借助于Condition
接口与newCondition()
方法。
ReentrantLock 和synchronized一样可以实现等待-通知机制,不同的是,被synchronized修饰的代码块(指定加锁的对象),在wait()方法后,需要其他线程调用同一个个对象的notify()方法,通知这个对象所有等待的线程。而ReentrantLock的锁对象,可以创建多个Condition接口的实现类,将(需要的)线程对象注册在指定的Conditon中,然后通过
signalAll()
方法就只会唤醒注册在该Condition
实例中的所有等待线程。实现有选择性的线程通知
6.4 Synchronized锁和Lock锁区别总结
- 不同之处:
1、synchronized是JVM层面实现的,java提供的关键字,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized
关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,Lock是API层面的锁。
2、synchronized不需要手动释放锁,底层会自动释放
-
synchronized
同步语句块的实现使用的是monitorenter
和motorexit
指令,其中monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。-
在执行
monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。 -
在执行
monitorexit
指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
-
Lock则需要手动释放锁,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成,否则有可能导致死锁。
3、Lock提供了更多的实现方法,synchronized等待不可中断,除非抛出异常或者执行完成,Lock可以中断,通过interrupt()可中断。
Lock() ; //获取锁
tryLock(); //获取锁
tryLock(long time, TimeUnit unit); //在一定时间单位内等待后,尝试获取锁;
lockInterruptibly(); //获取锁,可响应中断;
响应中断:
A、B 线程同时想获取到锁,A获取锁以后,B会进行等待,这时候等待着锁的线程B,会被Tread.interrupt()方法给中断等待状态、然后去执行其他的事情,
而synchronized锁无法被Tread.interrupt()方法给中断掉;
unlock(); //释放锁
4、synchronized是非公平锁,Lock能实现公平锁,它可以指定锁是公平锁还是非公平锁,默认是非公平的
5、synchronized无法判断,是否已经获取到锁,而Lock可以判断,是否已获取到锁;
6、 synchronized
关键字与wait()
和notify()
/notifyAll()
方法相结合可以实现等待/通知机制。ReentrantLock
类当然也可以实现,但是需要借助于Condition
接口与newCondition()
方法。
synchronized不可绑定多个条件,Lock可绑定多个条件,进行有选择性的通知,实现分组唤醒需要唤醒的锁。
————————————————
版权声明:本文为CSDN博主「超级可爱的小甜甜」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_41256881/article/details/106881396
六. volatile 关键字
1.分析JMM(Java 内存模型)--出现数据不一致问题
-
在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
2.解决数据不一致问题
3.并发编程的三个重要特性
- 原子性 : 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。
synchronized
可以保证代码片段的原子性。 - 可见性 :当一个变量对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
volatile
关键字可以保证共享变量的可见性。 - 有序性 :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。
volatile
关键字可以禁止指令进行重排序优化。(使用volatile
可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。)
4. synchronized 关键字和 volatile 关键字的区别
synchronized
关键字和 volatile
关键字是两个互补的存在,而不是对立的存在!
volatile
关键字是线程同步的轻量级实现,所以volatile
性能肯定比synchronized
关键字要好。但是volatile
关键字只能用于变量而synchronized
关键字可以修饰方法以及代码块。volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。volatile
关键字主要用于解决变量在多个线程之间的可见性,而synchronized
关键字解决的是多个线程之间访问资源的同步性。
七. ThreadLocal
1.ThreadLocal简介
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?
ThreadLocal
类主要解决的就是让每个线程绑定自己的值,是为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量- 可以使用
get()
和set()
方法来获取该线程下的副本变量,和将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
2.ThreadLocal 原理分析
2.1先理解一下ThreadLocalMap
每个Thread
中都具备一个ThreadLocalMap
,而ThreadLocalMap
可以存储以ThreadLocal
为 key ,Object 对象为 value 的键值对。
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
......
}
比如我们在同一个线程中声明了两个 ThreadLocal
对象的话,会使用 Thread
内部的ThreadLocalMap
存放数据,ThreadLocalMap
的 key 就是 ThreadLocal
对象(不同的ThreadLocal对象会有不同的hashCode值,故key不同),value 就是 ThreadLocal
对象调用set
方法设置的值(副本变量)。
2.2 ThreadLocal类的set/get方法
- 当前线程调用
ThreadLocal
类的set
或get
方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap
类对应的get()
、set()
方法。
//`ThreadLocal`类的`set()`方法
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
通过上面这些内容,我们足以通过猜测得出结论:
- 最终的变量是放在了当前线程的
ThreadLocalMap
中,并不是存在ThreadLocal
上,ThreadLocal
可以理解为只是ThreadLocalMap
的封装,传递了变量值。ThrealLocal
类中可以通过Thread.currentThread()
获取到当前线程对象后,直接通过getMap(Thread t)
可以访问到该线程的ThreadLocalMap
对象。然后再从这个在线程(Thread)中维护的map中取出数据或者存入对应数据。
3.ThreadLocal 内存泄露问题
-
ThreadLocalMap
中使用的 key 为ThreadLocal
的弱引用,而 value 是强引用。所以,如果ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap
中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。 -
解决:每次使用完ThreadLocal,都调用它的remove()方法,清除数据。
3.1回顾引用
-
引用类型:强引用,软引用,弱引用,虚引用(引用强度逐渐减弱)
-
强引用:垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
-
弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。
- 在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
- 但是具有软引用的对象,如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。
-
虚引用:任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。
八.线程池
1. 为什么要用线程池?
- 池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
- 使用线程池的好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
- 使用线程池的好处:
2. 实现 Runnable 接口和 Callable 接口的区别
-
Runnable
接口不会返回结果或抛出检查异常,但是Callable
接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用Runnable
接口,这样代码看起来会更加简洁。@FunctionalInterface public interface Runnable { /** * 被线程执行,没有返回值也无法抛出异常 */ public abstract void run(); } @FunctionalInterface public interface Callable<V> { /** * 计算结果,或在无法这样做时抛出异常。 * @return 计算得出的结果 * @throws 如果无法计算结果,则抛出异常 */ V call() throws Exception; }
3.执行 execute()方法和 submit()方法的区别是什么呢?
execute()
方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;submit()
方法用于提交需要返回值的任务。线程池会返回一个Future
类型的对象,通过这个Future
对象可以判断任务是否执行成功,并且可以通过Future
的get()
方法来获取返回值,get()
方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)
方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
4.创建线程池
4.1. 方式一:通过 Executor 框架的工具类 Executors 来实现
我们可以创建三种类型的线程池
- FixedThreadPool : 该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
- SingleThreadExecutor: 方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
- CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
- Executors 返回线程池对象的弊端:
- FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
- CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
4.2.方式二:通过构造方法创建(ThreadPoolExecutor)
推荐使用
ThreadPoolExecutor
类中提供的四个构造方法
ThreadPoolExecutor
中重要的参数:corePoolSize
: 核心线程数定义了线程池的基本大小,可以同时运行的线程数量;只有在工作队列满了的情况下才会创建超出这个数量的线程。maximumPoolSize
: 线程池中允许的最大线程数,线程池中的当前线程数目不会超过该值。如果队列中任务已满,并且当前线程个数小于maximumPoolSize,那么会创建新的线程来执行任务。- poolSize:线程池中当前线程的数量,当该值为0的时候,意味着没有任何线程,线程池会终止;同一时刻,poolSize不会超过maximumPoolSize。
workQueue
: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数corePoolSize
,如果达到的话,新任务就会被存放在队列中。如果未达到,就会新增加一个线程处理这个提交的新任务。
ThreadPoolExecutor
其他常见参数:
-
keepAliveTime
:当线程池中的**线程数量大于corePoolSize
**的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁; -
unit
:keepAliveTime
参数的时间单位。 -
threadFactory
:executor 创建新线程的时候会用到。 -
handler
:饱和策略-
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任时,
ThreadPoolTaskExecutor
定义的一些策略:-
ThreadPoolExecutor.AbortPolicy
:抛出RejectedExecutionException
来拒绝新任务的处理。 -
ThreadPoolExecutor.CallerRunsPolicy
:调用执行自己的线程运行任务,也就是直接在调用execute
方法的线程中运行(run
)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。 -
ThreadPoolExecutor.DiscardPolicy
: 不处理新任务,直接丢弃掉。 -
ThreadPoolExecutor.DiscardOldestPolicy
: 此策略将丢弃最早的未处理的任务请求。 -
eg:举个例子
- Spring 通过
ThreadPoolTaskExecutor
或者我们直接通过ThreadPoolExecutor
的构造函数创建线程池的时候,当我们不指定RejectedExecutionHandler
饱和策略的话来配置线程池的时候默认使用的是ThreadPoolExecutor.AbortPolicy
。
在默认情况下,
ThreadPoolExecutor
将抛出RejectedExecutionException
来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。 对于可伸缩的应用程序,建议使用ThreadPoolExecutor.CallerRunsPolicy
。当最大池被填满时,此策略为我们提供可伸缩队列。 - Spring 通过
-
-
- 线程池原理分析图
- 注意:在刚刚创建ThreadPoolExecutor的时候,线程并不会立即启动,而是要等到有任务提交时才会启动,除非调用了prestartCoreThread/prestartAllCoreThreads事先启动核心线程。
九. Atomic 原子类
1.简单介绍一下原子类
-
所谓原子类说简单点就是具有原子/原子操作特征的类。
-
在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。(一个操作是不可中断的)
-
并发包的原子类都放在
java.util.concurrent.atomic
下 -
原子类可以分为4种类型
-
基本类型
使用原子的方式更新基本类型的数据
AtomicInteger
:整形原子类AtomicLong
:长整型原子类AtomicBoolean
:布尔型原子类
数组类型
使用原子的方式更新数组里的某个元素
AtomicIntegerArray
:整形数组原子类AtomicLongArray
:长整形数组原子类AtomicReferenceArray
:引用类型数组原子类
引用类型
基本类型原子类只能更新一个变量,如果需要原子更新多个变量,需要使用 引用类型原子类
使用原子的方式更新该引用类中的数据
AtomicReference
:引用类型原子类AtomicStampedReference
:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。AtomicMarkableReference
:原子更新带有标记位的引用类型
对象的属性修改类型
如果需要原子更新某个类里的某个字段时,需要用到对象的属性修改类型原子类。
AtomicIntegerFieldUpdater
:原子更新整形字段的更新器AtomicLongFieldUpdater
:原子更新长整形字段的更新器AtomicReferenceFieldUpdater
:原子更新引用类型字段的更新器
-
2.使用原子类的优势
在多线程下,使用原子类,来更新数据,可以不用对方法加锁,也能保证线程安全!!!
2.1.以AtomicInteger
为例子
①多线程环境不使用原子类保证线程安全(基本数据类型)
class Test {
private volatile int count = 0;
//若要线程安全执行执行count++,需要加锁
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
②多线程环境使用原子类保证线程安全(基本数据类型)
class Test2 {
private AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet();
}
//使用AtomicInteger之后,不需要加锁,也可以实现线程安全。
public int getCount() {
return count.get();
}
}
2.2 悲观锁和乐观锁
-
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样当第二个线程想拿这个数据的时候,第二个线程会一直堵塞,直到第一个释放锁,他拿到锁后才可以访问。传统的数据库里面就用到了这种锁机制,例如:行锁,表锁,读锁,写锁,都是在操作前先上锁。java中的synchronized的实现就是一种悲观锁。
-
缺点:
-
在多线程竞争下,加锁、释放锁会导致较多的上下文切换和调度延时,引起性能问题
-
如果一个线程持有锁,其他的线程就都会挂起,等待持有锁的线程释放锁。
- 挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
-
如果一个优先级高的线程等待一个优先级低的线程释放锁,会导致优先级倒置,引起性能风险。
-
-
-
乐观锁:乐观锁概念为,每次拿数据的时候都认为别的线程不会修改这个数据,所以不会上锁,但是在更新的时候会判断一下在此期间别的线程有没有修改过数据,乐观锁适用于读操作多的场景,这样可以提高程序的吞吐量。在Java中java.util.concurrent.atomic包下面的原子类就是使用了乐观锁的一种实现方式CAS实现。
2.3.乐观锁的一种实现机制CAS
- 乐观锁主要就是两个步骤:冲突检测和数据更新。
- 当多个线程尝试使用CAS同时更新同一个变量时,只有一个线程可以更新变量的值,其他的线程都会失败,失败的线程并不会挂起,而是告知这次竞争中失败了,并可以再次尝试。
- CAS操作包括三个操作数:需要读写的内存位置的值(V)、预期原值(A)、新值(B)。如果内存位置的值与预期原值的A相匹配,那么将内存位置的值更新为新值B。如果内存位置与预期原值的值不匹配,那么处理器不会做任何操作。
2.4.原子类保证线程的原因
- AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
- CAS
- native本地方法用的是UnSafe 类的 objectFieldOffset(),这个方法是用来拿到“原来的值”的内存地址。
- 另外更新值 value 是一个volatile变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。
十.AQS
1. AQS类的介绍
- AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的
ReentrantLock
,Semaphore,ReentrantReadWriteLock等同步器 - 全称为(
AbstractQueuedSynchronizer
)抽象队列同步器 - 这个类在
java.util.concurrent.locks
包下面。
2. AQS 原理分析
2.1.AQS的核心原理
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制(不是线程休眠被唤醒的机制),这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程封装成一个结点Node加入到FIFO队列中,然后阻塞该线程(此处获取不到锁的线程虽阻塞,但会一直自旋等待锁的释放),当获取锁的线程释放锁之后,会从FIFO队列中唤醒一个阻塞的线程(结点)。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。
2.2 补充自旋锁和互斥锁的区别
-
互斥锁(mutexlock):最常使用于线程同步的锁;标记用来保证在任一时刻,只能有一个线程访问该对象,多个线程并发竞争锁的时候,没有抢到锁的线程会被挂起,进入休眠状态即sleep-waiting,当锁被释放的时候,处于休眠状态的一个线程会被唤醒,然后获取到锁。同一线程多次加锁操作会造成死锁
- 互斥锁的缺点:
- 挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
- 互斥锁的缺点:
-
自旋锁:自旋锁与互斥锁有点类似,只不过没有抢到锁的线程会一直自旋等待锁的释放,处于busy-waiting的状态,此时等待锁的线程并不是睡眠等待唤醒,而是循环检测保持者已经释放了锁,这样做的好处是节省了线程从睡眠状态到唤醒之间内核会产生的消耗,在加锁时间短暂的环境下这点会提高很大效率。
-
自旋锁的缺点:
-
1、自旋锁一直占用CPU,他在未获得锁的情况下,一直运行自旋,所以占用着CPU,如果不能在很短的时间内获得锁,这无疑会使CPU效率降低。
2、在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁,调用有些其他函数也可能造成死锁,如 copy_to_user()、copy_from_user()、kmalloc()等。
-
2.3 AQS的原理分析
https://blog.csdn.net/qq_40728028/article/details/106444575?ops_request_misc
-
AQS 使用一个 int 成员变量state来表示这个共享资源的同步状态
-
通过内置的 FIFO 队列来完成获取资源线程的排队工作。(一般通过头结点的顺序依次往下让线程公平地获取锁,如果头结点,被处理掉,或为空,则会从队尾结点的线程开始获取锁)
-
AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改(从而实现锁的分配)。
private volatile int state;//共享变量,使用volatile修饰保证线程可见性
状态信息通过 protected 类型的 getState,setState,compareAndSetState 进行操作
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子(CAS操作),如果当前同步状态的值等于expect(期望值),将同步状态值设置为给定值update
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
3. AQS对资源的共享方式
- Exclusive(独占):只有一个线程能执行,如
ReentrantLock
(同步器)------>又可分为公平锁和非公平锁:- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
- Share(共享):多个线程可同时执行,如
CountDownLatch
、Semaphore
、CountDownLatch
、CyclicBarrier
、ReadWriteLock
(同步器)ReentrantReadWriteLock
可以看成是组合式,因为ReentrantReadWriteLock
也就是读写锁允许多个线程同时对某一资源进行读。
4.AQS的底层使用模板方法模式(用来创建不同的同步器)
4.1 模板方法模式
- 定义一个模板结构,将具体内容延迟到子类去实现。
- 作用:模板方法模式是基于”继承“的,主要是为了在不改变模板结构的前提下在子类中重新定义模板中的内容以实现复用代码。
4.2自定义同步器简单了解
-
不同的自定义同步器争用共享资源的方式是不同的。
-
自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在底层实现好了。
-
AQS已经将同步器的底层实现好了:
-
java并发包下很多API都是基于AQS来实现的加锁和释放锁等功能的,AQS是java并发包的基础类。
举个例子,比如说ReentrantLock、ReentrantReadWriteLock底层都是基于AQS来实现的。说白了,ReentrantLock内部包含了一个AQS对象,也就是AbstractQueuedSynchronizer类型的对象。
这个AQS对象就是ReentrantLock可以实现加锁和释放锁的关键性的核心组件。
-
-
4.3 创建自定义同步器
模板方法模式一个经典的应用
- 1)使用者继承
AbstractQueuedSynchronizer
并重写指定的方法。(这些重写方法很简单,无非是对于共享资源 state 的获取和释放) - 2)将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的模板方法:
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
4.4 基于AQS的ReentrantLock是如何实现共享资源 state 的获取与释放的?
- 以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程 lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。
4.5 基于AQS的CountDownLatch是如何实现共享资源 state 的获取与释放的?(见下面5.CoutDownLatch倒计时器)
- 再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown()一次,state 会 CAS(Compare and Swap)减 1。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 await()函数返回,继续后余动作。
5.基于AQS的同步器(扩充)
-
Semaphore
(信号量)-允许多个线程同时访问:synchronized
和ReentrantLock
都是一次只允许一个线程访问某个资源,Semaphore
(信号量)可以指定多个线程同时访问某个资源。 -
CountDownLatch
(倒计时器):CountDownLatch
是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。-
CountDownLatch在底层如何实现对共享资源state的获取与释放的?
-
CountDownLatch
允许count
个线程阻塞在一个地方,直至所有线程的任务都执行完毕。CountDownLatch
是共享锁的一种实现,它默认构造 AQS 的state
值为count
。当线程使用countDown()
方法时,其实使用了tryReleaseShared
方法以 CAS 的操作来减少state
,直至state
为 0 。当调用await()
方法的时候,如果state
不为 0,那就证明任务还没有执行完毕,await()
方法就会一直阻塞,也就是说await()
方法之后的语句不会被执行。然后,CountDownLatch
会自旋 CAS 判断state == 0
,如果state == 0
的话,就会释放所有等待的线程,await()
方法之后的语句得到执行。
-
-
CountDownLatch 的两种典型用法
- 某一线程在开始运行前等待 n 个线程执行完毕。将 CountDownLatch 的计数器初始化为 n :
new CountDownLatch(n)
,每当一个任务线程执行完毕,就将计数器减 1countdownlatch.countDown()
,当计数器的值变为 0 时,在CountDownLatch上 await()
的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。 - 实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的
CountDownLatch
对象,将其计数器初始化为 1 :new CountDownLatch(1)
,多个线程在开始执行任务前首先coundownlatch.await()
,当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。
- 某一线程在开始运行前等待 n 个线程执行完毕。将 CountDownLatch 的计数器初始化为 n :
-
CountDownLatch 的不足
- CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。
-
-
CyclicBarrier
(循环栅栏):CyclicBarrier
和CountDownLatch
非常类似,它也可以实现线程间的技术等待,但是它的功能比CountDownLatch
更加复杂和强大。- CountDownLatch(倒计时器)和
CyclicBarrier
(循环栅栏)两者的区别:- CyclicBarrier 的计数器提供 reset 功能,可以多次使用。
- CountDownLatch(倒计时器)重点是:需要执行的一个或者多个线程的等待(要在其他线程执行完毕后才能执行,是对其他线程执行完毕的计数)
CyclicBarrier
(循环栅栏)重点是:多个线程执行,在任意一个线程没有完成,所有的线程都必须等待。(是对需要执行的线程执行完毕的计数)
- CountDownLatch(倒计时器)和