文章目录
- 常见锁策略
- 乐观锁 vs 悲观锁
- 重量级锁 vs 轻量级锁
- 自旋锁 vs 挂起等待锁
- 读写锁
- 可重入锁 vs 不可重入锁
- 公平锁 vs 非公平锁
- 面试相关题
- CAS
- 什么是CAS
- CAS 是怎么实现的
- CAS 有哪些应用
- 1)实现原子类
- 2)实现自旋锁
- CAS的ABA问提
- 什么是ABA问提
- ABA问提引来的BUG
- 解决方法
- 相关面试题
- Synchronized原理
- 基本特点
- sychronized几个重要机制
- 锁升级
- 1)偏向锁
- 2)轻量级锁
- 3) 重量级锁
- 锁消除
- 锁粗化
- JUC(java.util.concurrent)的常见类
- Callable接口
- Callable的用法
- 创建线程的方法
- ReentrantLock
- 原子类
- 线程池
- ExecutorService 和 Executors
- ThreadPoolExecutor
- 信号量Semaphore
- CountDownLatch
- 面试相关题
- 线程安全集合类
- 多线程环境使用ArrayList
- 多线程环境使用队列
- 多线程使用哈希表
常见锁策略
乐观锁 vs 悲观锁
悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁
乐观锁
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
Synchronized初始使用乐观锁策略.当发现锁竞争比较频繁时,就会自动切换成悲观锁
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突,我们可以引入一个"版本号"来解决
假设我们需要多线程修改"用户账户余额"
设当前余额100,引入一个版本号version,初始值为1,并且我们规定"提交版本必须大于记录当前版本才能执行余额更新"1)线程A此时准备将其读出(version=1,balance=100),线程B也读出此信息(version(version=1,balance=100)
- 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20( 100-20 )
- 线程 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50),写回到内存中;
- 线程 B 完成了操作,也将版本号加1( version=2 )试图向内存中提交数据( balance=80),但此时比对版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不满足"提交版本必须大于记录当前版本才能执行更新"的乐观策略,就认为这次操作失败
重量级锁 vs 轻量级锁
锁的核心特性"原子性",这样的机制追根溯源是cpu这样硬件设备提供的
- CPU提供了"原子操作指令"
- 操作系统基于CPU的原子指令,实现mutex互斥锁
- JVM基于操作系统提供的互斥锁,实现了synchronized和Reentrantlock等关键字和类
重量级锁:加锁机制重度依赖操作系统提供的mutex - 大量的内核态用户切换
- 很容易引发线程调度
这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着要花很多时间
轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.
- 少量的内核态用户态切换.
- 不太容易引发线程调度
synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
自旋锁 vs 挂起等待锁
自旋锁
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.
但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.
一旦锁被其他线程释放, 就能第一时间获取到锁.
自旋锁是一种典型的 轻量级锁 的实现方式
- 优点:没有放弃cpu,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁
- 缺点:如果锁被其他线程持有时间比较久,那么就会持续消耗CPU资源(而挂起等待的时候是不消耗CPU的)
挂起等待锁
要借助OS的api来实现,一旦出现锁竞争,就会在内核中触发一系列动作(比如让这个线程进入阻塞状态,暂时不参与cpu调度)
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的
读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方和写入方之间以及写入方和读取方之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
一个线程对与数据的访问,主要存在两个操作:读数据和写数据.
- 两个线程都只是读一个数据,此时并没有线程安全问提,直接并发读取即可
- 两个线程都要写一个数据,有线程安全问题
- 一个线程读另一个线程写,也有现成安全问题
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
-
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
-
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
-
读加锁和读加锁之间, 不互斥
-
写加锁和写加锁之间, 互斥
-
读加锁和写加锁之间, 互斥
注意, 只要是涉及到 “互斥”, 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多久了.
因此尽可能减少 “互斥” 的机会, 就是提高效率的重要途径
读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的)
Synchronized 不是读写锁
可重入锁 vs 不可重入锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的
而 Linux 系统提供的 mutex 是不可重入锁
第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第
二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁 ,这样的锁称为 不可重入锁
synchronized 是可重入锁
公平锁 vs 非公平锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生啥呢?
公平锁:遵循"先来后到",B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁:不遵守 “先来后到”. B 和 C 都有可能获取到锁.
注:
- 操作系统内部的线程调度就可以视为是随机的,如果不做任何额外的限制,锁就是非公平锁,如果想要实现公平锁,就需要额外的数据结构(如队列),来记录线程们的先后顺序
- 公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
synchronized 是非公平锁.
面试相关题
- 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大,所以会在每次访问共享变量之前都会去真正加锁
乐观锁认为多个线程访问同一个共享变量冲突的概率较小,并不会真的加锁,而是直接尝试访问数据,在访问的同时识别当前的数据是否出现访问冲突
- 介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁
读锁和读锁之间不互斥
写锁和写锁之间互斥
写锁和读锁之间互斥
读写锁最主要在"频繁读,不频繁写"的场景中
- 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败,立即再尝试获取锁,无限循环,知道获取到锁为止.第一次获取锁失败,第二次的尝试会在极短时间内到来,一旦锁被其他线程释放,就能第一时间获取到锁
相比于挂起等待锁:
优点:没有放弃CPU资源,一旦锁被释放就能第一时间获取到锁, 更高效.在锁持有时间比较短的场景下非常有用
缺点:如果锁的持有时间较长,就会浪费cpu资源
- synchronized 是可重入锁么?
是可重入锁
可重入锁指的就是连续两次加锁不会导致死锁
实现的方法是在锁中记录持有该锁的线程身份,以及一个计数器(记录加锁次数),如果发现当前加锁的线程就是持有锁的线程,则直接计数自增
CAS
什么是CAS
CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,寄存器中旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功。
CAS伪代码
下面的代码不是原子的,真实的CAS是一个**原子的硬件指令(cpu指令)**完成的,这个伪代码只是辅助理解
CAS 的工作流程
一个CPU指令就能完成上述比较交换的逻辑
单个cpu指令,是原子的,就可以使用CAS完成一些操作,进一步代替加锁
给编写线程安全的代码,引入新的思路
基于CAS实现线程安全的方式,也称为"无锁编程"
优点:保证线程安全,同时避免阻塞(效率)
缺点:1.代码更复杂,不好读
2.只能够适应一些特定场景,不如加锁方法普适
两种典型的不是"原子性"的代码
1.check and set (if判定然后设定值)
2.read and update (i++ lord add save)
CAS 是怎么实现的
CAS本质上是cpu提供的指令=>又被操作系统封装,提供成api=>又被JVM封装,也提供成api=>被程序员使用
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲
- java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
- unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
- Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性
简而言之,是因为硬件予以了支持,软件层面才能做到
在java中,有些操作是偏底层的操作,偏底层的操作在使用的时候有更多的注意事项
稍有不慎就容易写出问提
这些操作,就放到unsafe中进行归类
CAS 有哪些应用
1)实现原子类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
AtomicInteger atomicInteger = new AtomicInteger (0);
//相当于i++
atomicInteger.getAndIncrement ();
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
假设两个线程同时调用getAndIncrement
-
两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)
-
线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值
注意:
CAS 是直接读写内存的, 而不是操作寄存器.
CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的
-
线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环.
在循环里重新读取 value 的值赋给 oldValue -
线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作.
-
线程1 和 线程2 返回各自的 oldValue 的值即可.
native修饰的方法,成为"本地方法",也就是在JVM源码中,使用C++实现的逻辑,涉及到一些底层操作
2)实现自旋锁
基于 CAS 实现更灵活的锁, 获取到更多的控制权.
自旋锁伪代码
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
CAS的ABA问提
什么是ABA问提
假设两个线程t1和t2,有一个共享变量num,初始值为A
解下来,t1想使用CAS把num值改为Z,那么就需要
- 先读取num的值,记录到oldNum变量中
- 使用CAS判定当前num的值是否为A,如果为A,就修改为Z
但是,在t1执行这两个操作之间,t2可能把num值从A改成B,又从B改成了A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这
个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程
ABA问提引来的BUG
部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况
假设 我 有 100 存款. 我想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.正常的过程
1)存款100,线程1获取到当前存款值为100,期望更新为50;线程2获得当前存款值为100,期望更新为 50.
2) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
3) 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.*异常的过程
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 在线程2 执行之前, 我的朋友正好给我转账 50, 账户余额变成 100 !!
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作这个时候, 扣款操作被执行了两次!!!
解决方法
给要修改的值,引入版本号,在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期
- CAS操作在读取旧值的同时,也要读取版本号
- 真正修改的时候:
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
假设 我 有 100 存款. 我想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
- 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100,版本号为 1, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成3.
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读到的版本号为 1, 版本小于当前版本, 认为操作失败
在java标准库中提供了AtomicStampedReference类 这个类可以对某个类进行包装,在内部就提供了上面描述的版本管理功能
相关面试题
1)讲一下你自己理解的CAS
CAS全程Compare and Swap,即比较并交换,相当于通过一个原子的操作,同时完成"读取内存,比较是否相等,修改内存"这三个步骤,本质上需要CPU指令的支撑
2)ABA问题怎么解决?
给要修改的数据引入版本号,在CAS比较数据当前值和旧值的同时也要比较版本号是否符合预期.如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败
Synchronized原理
基本特点
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
- 开始时是乐观锁,如果锁冲突频繁,就转化为悲观锁
- 开始是轻量级锁实现,如果锁持有时间较长,就转换为重量级锁
- 实现轻量级锁的时候大概率会用到自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
sychronized几个重要机制
锁升级
JVM将synchronized锁分为无锁,偏向锁,轻量级锁,重量级锁状态.会根据情况,一次进行升级
1)偏向锁
第一个尝试加锁的线程,优先进入偏向锁状态
偏向锁不是真的加锁,只是给对象头中做一个"偏向锁标记",记录这个锁属于哪个线程
如果后续没有其他线程来竞争锁,那么就不用进行其他同步操作了(避免加锁解锁开销)
如果后续有其他线程来竞争该锁,就取消原来的偏向锁状态,进入一般轻量级锁状态
偏向锁本质相当于"延迟加锁", 能不加锁就不加锁, 尽量来避免不必要的加锁开销
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁
偏向锁的核心思想,就是"懒汉模式"的;另一种体现
线程池,是优化了"找下一任"效率
偏向锁,是优化了"分手"的效率
2)轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
- 此处的轻量级锁就是通过 CAS 来实现
- 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
- 如果更新成功, 则认为加锁成功
- 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU)
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.
也就是所谓的 "自适应
3) 重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
- 执行加锁操作, 先进入内核态.
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用, 则加锁成功, 并切换回用户态.
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
- 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.
锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除
什么是 “锁消除”
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加
锁解锁操作是没有必要的, 白白浪费了一些资源开销.
锁消除,这个过程是编译过程触发的
偏向锁,实在运行过程中触发的
锁粗化
锁的粒度:synchronized里头,代码越多,锁的粒度越粗;代码越少,锁的粒度越细
粒度细时,能够并发执行的逻辑更多,更有利于充分利用多核cpu资源
但是,如果粒度细的锁,被反复进行加锁解锁,可能实际效果还不如粒度粗的锁(设计反复的锁竞争)
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化
实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁
但实际可能并没有其他线程来抢占这个锁, 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁
JUC(java.util.concurrent)的常见类
Callable接口
Callable的用法
这也是一种创建线程的方式
适合于想让某个线程执行一个逻辑,并且返回结果的时候
相比之下,Runnale不关注结果
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序员借助多线程的方式计算结果.
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 不使用 Callable 版本
- 创建一个类 Result , 包含一个 sum 表示最终结果, lock 表示线程同步使用的锁对象.
- main 方法中先创建 Result 实例, 然后创建一个线程 t. 在线程内部计算 1 + 2 + 3 + … + 1000.
- 主线程同时使用 wait 等待线程 t 计算结束. (注意, 如果执行到 wait 之前, 线程 t 已经计算完了, 就不 必等待了).
- 当线程 t 计算完毕后, 通过 notify 唤醒主线程, 主线程再打印结果
static class Result {
public int sum = 0;
public Object lock = new Object();
}
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread() {
@Override
public void run() {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
synchronized (result.lock) {
result.sum = sum;
result.lock.notify();
}
}
};
t.start();
synchronized (result.lock) {
while (result.sum == 0) {
result.lock.wait();
}
System.out.println(result.sum);
}
}
可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错.
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本
public class Demo31 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer> () {
@Override
public Integer call() throws Exception {//call是callable的核心方法,返回值是Integer
int sum = 0;
for (int i = 0; i <=1000; i++) {
sum+=i;
}
return sum;
}
};
//把任务放到线程中去执行
FutureTask<Integer> futureTask = new FutureTask<> ( callable );
Thread t = new Thread (futureTask);
t.start ();
//此时的get 就能获取到callable里面返回值的结果
//由于线程是并发执行的,执行到主线程的get的时候,t线程可能还没完成执行
//没执行的话,get会阻塞
System.out.println (futureTask.get ());
}
}
理解 Callable
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,
Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为
Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作
理解 FutureTask
想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是
FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没
创建线程的方法
1.继承Thread类,重写run(创建单独的类,也可以匿名内部类)
2.实现Runnable接口,重写run(创建单独的类,也可以匿名内部类)
3.实现Callable接口,重写run(创建单独的类,也可以匿名内部类)
4.使用lambda表达式
5.ThreadFactory线程工厂
6.线程池
ReentrantLock
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全
ReentrantLock 的用法:
- lock(): 加锁, 如果获取不到锁就死等
- unlock(): 解锁
- trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁
ReentrantLock lock = new ReentrantLock();
-----------------------------------------
lock.lock();
try {
// working
} finally {
lock.unlock()
}
ReentrantLock 和 synchronized 的区别:
- synchronized是一个关键字,是JVM内部实现的, ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现)
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,但是也容易遗漏 unlock
- synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个true 开启公平锁模式
- ReentrantLock提供了更强大的等待通知,搭配了Condition类,实现等待通知
原子类
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference
以 AtomicInteger 举例,常见方法有
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i–;
incrementAndGet(); ++i;
getAndIncrement(); i++
线程池
虽然创建销毁线程比创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是会比较低效
线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 "池子"中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了.
ExecutorService 和 Executors
代码示例:
- ExecutorService 表示一个线程池实例.
- Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
- ExecutorService 的 submit 方法能够向线程池中提交若干个任务.
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
Executors 创建线程池的几种方式
- newFixedThreadPool: 创建固定线程数的线程池
- newCachedThreadPool: 创建线程数目动态增长的线程池.
- newSingleThreadExecutor: 创建只包含单个线程的线程池.
- newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
Executors 本质上是 ThreadPoolExecutor 类的封装
ThreadPoolExecutor
ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定
理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
keepAliveTime: 临时工允许的空闲时间.
unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
workQueue: 传递任务的阻塞队列
threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
AbortPolicy(): 超过负荷, 直接抛出异常.
CallerRunsPolicy(): 调用者负责处理
DiscardOldestPolicy(): 丢弃队列中最老的任务.
DiscardPolicy(): 丢弃新来的任务.
信号量Semaphore
信号量,就是一个计数器,用来表示可用资源的个数
理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
这里的阻塞等待有种锁的感觉
锁,本质上就是属于一种特殊的信号量
锁就是可用资源为1的信号量(二元信号量)
加锁操作,P操作,1->0;
解锁操作,V操作,0->1;
Semaphore的PV操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用
代码示例
- 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
- acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
- 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println("申请资源");
semaphore.acquire();
System.out.println("我获取到资源了");
Thread.sleep(1000);
System.out.println("我释放资源了");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
CountDownLatch
CountDownLatch主要适用于,多个线程完成一些列任务的时候,用来衡量任务的进度是否完成
比如把一个大的任务,拆分成多个小的任务,让这些任务并发执行,同时等待 N 个任务执行结束
就可以用CountDownLatch来判定说当前这些任务是否全部完成了
- 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成
- 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
- 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch =new CountDownLatch ( 10 );
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread (()->{
System.out.println ("thread"+id);
//通知当前任务执行完毕了
countDownLatch.countDown ();
});
t.start ();
}
countDownLatch.await ();
System.out.println ("所有的任务都完成了");
}
面试相关题
- 线程同步的方式有哪些?
synchronized,ReentranLock,Semaphore等都用于线程同步
- 为什么有了 synchronized 还需要 juc 下的 lock?
以 juc 的 ReentrantLock 为例
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活
- synchronized申请锁失败会死等,ReentrantLock可以通过trylock的方式等待一段时间就放弃
synchronized是非公平锁,ReentrantLock默认是非公平锁,可以通过构造方法出入一个true开启公平锁模式
synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的
线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
3)信号量听说过么?之前都用在过哪些场景下?
信号量,用来表示"可用资源个数",本质上是一个计数器
使用信号量可以实现"共享锁",比如某个资源允许3个线程同时使用,那么就可以使用P操作作为加锁,V操作作为解锁,前三个线程的P操作都能顺利返回,后续线程再进行P操作就会阻塞等待
知道前面的线程执行V操作
线程安全集合类
原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的
多线程环境使用ArrayList
1)自己使用同步机制(synchronized 或者 ReentrantLock)
2)Collections.synchronizedList(new ArrayList)
这个东西会返回一个新的对象,这个新的对象就相当于给ArrayList套了一层壳
这层壳就是在方法上直接使用synchronized的
3)使用CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器
- 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素
- 添加完元素之后,再将原容器的引用指向新的容器
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下,性能很高,不需要锁竞争
缺点:
1.占用内存较多
2.写时的数据不能被第一时间读取到
3.当容器较大时,复制一份所消耗的资源可能比加锁更多
多线程环境使用队列
1)ArrayBlockingQueue
基于数组实现的阻塞队列
2)LinkedBlockingQueue
基于链表实现的阻塞队列
-
PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列 -
TransferQueue
最多只包含一个元素的阻塞队列
多线程使用哈希表
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
- Hashtable
- ConcurrentHashMap
1)Hashtable
只是简单的把关键方法加上了 synchronized 关键字
public synchronized V put(K key,V value){
}
public synchronized V get(Object key){
}
这相当于直接针对 Hashtable 对象本身加锁
- 如果多线程访问同一个Hashtable就会直接造成锁冲突
- size属性也是通过synchronized来控制同步的,也是比较慢
- 一旦触发扩容器,就由该线程完成整个扩容的过程,这个过程会涉及到大量的元素拷贝,效率非常低
2)ConcurrentHashMap
相比于Hashtable做出一系列的改进和优化. 以 Java1.8 为例
- 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是是用 synchronized, 但是不是锁整个对象, 而是 “锁桶” (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率
- 充分利用CAS特性, 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况
- 优化了扩容方式:化整为零
-
- 发现需要扩容的线程,只需要创建一个新的数组,同时只搬运几个元素过去
-
- 扩容期间,新老数组同时存在
-
- 后续每个来操作ConcurrentHashMap的线程,都会参与搬家的过程,每个操作负责搬运一小部分元素
-
- 搬完最后一个元素再把老数组删掉
-
- 这个期间, 插入只往新数组加
-
- 这个期间, 查找需要同时查新数组和老数组