常见的锁策略
1. 乐观锁和悲观锁
- 乐观锁
- 乐观锁假设认为数据一般不会产生并发冲突, 所以在数据进行提交的时候, 才会正式对数据是否产生并发冲突进行检测, 如果发生并发冲突了, 就返回错误信息, 让程序猿决定怎么去做.
- 也就是: 保持乐观心态, 认为在大多数情况下, 同一个时间点, 只有一个线程执行修改操作.
- 缺点: 并不总是能解决所有问题, 所以会引入一定的系统复杂度
- 乐观锁一般适用于写比较少的场景(多读场景)
- 乐观锁一般是使用版本号机制或者CAS算法实现
- 悲观锁
- 悲观锁是总是设想最坏的情况, 每次去拿数据的时候都认为别人会修改, 所以在每次拿数据的时候都会上锁, 这样别人在拿这个数据的时候都会阻塞等待知道他拿到锁(共享资源每次只给一个线程使用, 其他线程阻塞等待, 用完之后再把资源转让给其他线程)
- 也就是: 保持悲观心态, 总是认为多个线程同一个时间点执行修改操作
- 缺点: 总是需要竞争锁, 进而导致线程切换, 挂起其他线程, 性能不高
- 悲观锁一般适用于多写的场景
- Java中synchronized和ReentrantLock等独占锁就是悲观锁的实现
2. 读写锁
读写锁简介
- 为了解决多线程安全问题, 我们几乎会高频率的使用到独占式锁, 通常是使用java提供的关键字synchronized或者concurrent包中实现了Lock接口的ReentrantLock(可重入互斥锁), 他们都是独占式获取锁,也就是在同一时刻只有一个线程能获取到锁; 但是在大部分场景下, 只是读数据, 写数据很少, 这样就会出现性能瓶颈; 针对这种读多写少的情况, java还提供了另外一个实现了Lock接口的ReentrantReadWriteLock(读写锁).读写锁允许同一时刻被多个线程访问, 但是在写线程访问的时候, 所有的读线程和其他的写线程都会被阻塞.
- 适合的场景: 读读并发执行, 读写/写写互斥. 在读写分离的场景下效率更高(写锁能够降级为读锁)
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class FileReadWrite {
public static void main(String[] args) {
ReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
// 20个线程对同一个文件进行读操作
for (int i = 0; i < 20; i++) {
new Thread(() -> {
readLock.lock();
try {
// 文件读操作
} finally {
readLock.unlock();
}
}).start();
}
// 20个线程对同一个文件进行写操作
for (int i = 0; i < 20; i++) {
writeLock.lock();
new Thread(() -> {
try {
// 文件写操作
} finally {
writeLock.unlock();
}
}).start();
}
}
}
3. 自旋锁
- 实际情况下, 线程在抢锁失败后, 过不了多久, 锁就会被重新释放.
- 自旋锁就是获取不到锁的线程不会立即阻塞等待, 而是通过循环的方式不断的尝试去获取锁(获取不到锁, 就死等)
- 可以理解为自旋锁就是下面这行代码
while(抢锁(lock) == 失败){}
- 自旋锁的缺点: 如果锁在被抢后没有很快释放, 那么自旋锁的线程就是长期在做无用功, 消耗CPU资源
4.可重入锁
- 可重入锁就是: 可以进入的锁, 即允许同一个线程多次获取同一把锁
- 比如在递归中, 一个递归函数里有加锁操作, 递归过程中这个锁不会阻塞自己, 那么这个锁就是可重入锁(因为这个原因可重入锁也叫递归锁)
- Java中只要是以Reentrant开头命名的都是可重入锁, JDK提供的所有现成的Lock实现类, 包括synchronized关键字锁都是可重入锁.
CAS
1. 什么是CAS
- CAS:Compare andswap,比较并交换
- 一个CAS涉及以下操作:
- 假设内存中有原数据V, 有旧的预期值A, 以及需要修改的新值B
- 第一步: 比较V和A的值是否相等(比较)
- 第二步: 如果比较相等, 那么把B的值写入V(交换)
- 返回boolean类型值, 表示操作是否成功
- 当多个线程同时对某个资源进行CAS操作的时候, 只能有一个线程操作成功, 但是并不会阻塞其他线程(每个线程都是运行态), 其他线程只会受到操作失败的信号(只有一个操作成功, V的值已经被修改, 再判断A是否等于V就会返回false)
- 可见, CAS其实是一个乐观锁
2. CAS的底层实现
- 针对不同的操作系统, JVM用到了不同的CAS实现原理
- 简单来说就是CPU+操作系统+JDK的一个unsafe类
3. 对于CAS中的ABA问题
- ABA问题就是一个值从A变成了B, 又从B变成了A, 而这个期间我们不知道这个过程.
- 线程执行第一步和第三步之间, 有其他的线程修改了主内存的变量值A->B->A
- 解决方案: 引入版本号(例如携带AtomicStampedReference之类的时间戳作为版本信息)
4. JDK中使用CAS实现的API
-
java.util.concurrent.atomic: 原子性的并发包(之下的很多API几乎都使用了CAS实现)
-
AtomicInteger, AtomicBoolean, AtomicLong…
-
实现原理:
CAS + 自旋
再谈synchronized
1. monitor机制
编译为字节码文件时, 会生成多个monitorenter + 多个monitorexit指令, 计数器会进行重入的计数.
2. 对象头的锁机制
3. synchronized的优化方案
(1) 根据不同的场景使用不同的加锁方式
JVM将synchronized锁分为无锁, 偏向锁, 轻量级锁, 重量级锁状态. 会根据情况, 进行依次升级.
- 无锁: 没有对资源进行锁定, 所有的线程都能访问并修改同一个资源, 但是同时只有一个线程能修改成功, 其他失败的线程会不断重试知道修改成功.
- 偏向锁: 对象的代码一直被同一个线程执行, 不存在多个线程竞争, 该线程在后续的执行过程中自动获取锁, 降低获取锁所带来的性能开销. 偏向锁其实就是指: 偏向第一个加锁线程, 该线程是不会主动释放偏向锁的, 只有当其他线程尝试竞争偏向锁才会被释放
偏向锁的撤销, 需要在某个时间点上没有字节码文件执行的时候, 先暂停拥有偏向锁的线程, 然后判断然后判断锁对象是否处于被锁定的状态. 如果线程不处于活动状态, 就将对象头设置为无锁状态, 并撤销偏向锁; 如果线程处于活动状态, 就升级为轻量级锁的状态.
- 使用场景:重入操作: 同一个线程已经持有某个对象锁, 下一次再次申请该对象锁.
- 轻量级锁: 轻量级锁就是当锁是偏向锁的时候, 被第二个线程访问, 此时偏向锁就会升级为轻量级锁, 第二个线程会通过自旋的形式尝试获取锁, 线程不会阻塞, 从而提高性能.
当线程自旋超过一定次数时, 轻量级锁会升级为重量级锁; 当一个线程持有锁, 另一个线程在自旋尝试获取锁, 而此时又有第三个线程来访时, 轻量级锁也会升级为重量级锁.
- 使用场景: 同一个时间点, 只有另外一个线程来竞争同一个对象锁
- 实现原理: CAS
- 重量级锁:指当有一个线程获取到锁之后, 其余所有等待获取该锁的线程都会处于阻塞状态.
- 使用场景: 同一个时间点, 多个线程竞争同一个对象锁
- 特性: 会阻塞线程, 竞争失败的线程会在阻塞态以及被唤醒的状态切换, 极消耗性能
- 原理: 重量级锁通过对象内部的monitor监视器实现, 而monitor的本质是依赖于底层操作系统的Mutex Lock实现
按照开销/代价高低: 无锁, 偏向锁, 轻量级锁, 重量级锁 —— 锁只能升级不能降级.
(2) 锁粗化
多次加锁解锁操作合并为一次
(3) 锁消除
删除不必要的加锁操作.
根据代码逃逸技术, 如果判断出一段代码中, 堆上的数据不会逃逸出当前线程, 那么可以认为这段代码是线程安全的, 不必要加锁.如局部变量的加锁操作.
死锁
导致死锁的原因
多个线程同时执行, 当两个线程互相持有对方所需要的资源, 又不主动释放资源, 导致所有线程都无法继续执行, 导致线程进入无尽的阻塞, 造成死锁.
死锁产生的四个必要条件
- 互斥使用: 即当资源正在被一个线程使用时, 别的线程不能使用.
- 不可抢占: 资源请求者不能强制从资源占有者手中夺取资源, 资源只能由资源占有者主动释放.
- 请求和保持: 即当资源请求者在请求其他的资源的同时保持对原有资源的占有.
- 循环等待: 即存在一个等待队列, 线程1占有线程2的资源, 线程2占有线程3的资源, 线程3占有线程1的资源, 这样就形成了一个等待环路.
如果以上四个条件都满足, 就会形成死锁.
解决方案
死锁的情况下如果打破上述任何一个条件, 便可让死锁消失.
- 资源一次性分配: 破坏请求和保持.
- 可剥夺资源: 在线程满足条件时, 释放掉已占有的资源.
- 资源有序分配: 系统为每类资源赋予一个编号, 每个线程按照编号请求资源, 释放则相反.
检测手段
jstack查看线程状态, 包括死锁.
Callable创建线程
可以通过Future.get()阻塞当前线程, 并获取线程对象的返回值(相当于join方法, 只是get()方法提供了一个返回值)
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Test {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<String> c = new Callable<String>() {
@Override
public String call() throws Exception {
System.out.println("call");
return "ok";
}
};
FutureTask task = new FutureTask(c);
Thread t = new Thread(task);
t.start();
System.out.println(task.get());
System.out.println("main");
}
}
创建线程的方式:
(1) 继承Thread类
(2) 实现Runnable接口
(3) Callable创建线程
Lock体系
1. Lock
Lock简介
- Lock没有Synchronized关键字隐式加锁解锁的便捷性, 但是却拥有锁获取和释放的可操作性, 可中断的获取锁以及超时获取锁等Synchronized关键字不具备的同步特性.
语法
Lock lock = new ReentrantLock();
lock.lock();
try{
.......
}finally{
lock.unlock();
}
synchronized同步块执行完成或者遇到异常时锁会自动释放; 而Lock必须调用unlock()方法释放锁, 因此得再finally块中释放锁.
Lock接口API
lock接口的实现子类
2. AQS
AbstractQueueSynchronized ---- 抽象的队列式的同步器
- AQS实现了: 对同步状态的管理, 以及对阻塞线程进行排队, 等待通知等一系列底层的实现
- AQS的核心也包括了这些方面: 同步队列, 独占式锁的获取和释放, 共享锁的获取和释放以及可中断锁,超时等待锁获取这些特性的实现.
- 作用:对线程进行加锁, 释放锁操作
- 原理: 双端队列 + CAS ----- 进行线程同步状态的管理
3. ReentrantLock
简述ReentrantLock
ReentrantLock重入锁, 是实现了Lock接口的一个类, 也是使用频率很高的一类锁. 支持重入性, 表示对共享资源可以重复加锁, 即当前线程获取该锁后再次获取该锁不会被阻塞.
使用语法
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// working
} finally {
lock.unlock()
}
重入性(重入锁)
要想支持重入性, 就要解决两个问题:
- 在线程获取锁的时候, 如果已经获取锁的线程是当前线程的话则直接再次获取成功.
- 如果锁被获取n次, 那么只有锁在释放同样的n次之后, 该锁才算是完全释放成功了.
公平锁与非公平锁
- 公平锁每次都是从同步队列中的第一个节点获取到锁, 而非公平锁则不一定, 有可能这个线程刚释放锁又重新获取到锁.
- 所有线程加锁的时候, 是否按照存放在队列的时间顺序来加锁(设置线程同步状态), 满足时间顺序就是公平锁, 不满足就是非公平锁
公平锁vs非公平锁
- 公平锁每次获取到的锁是同步队列中的第一个节点, **保证了请求资源时间上的绝对顺序,**而非公平锁有可能刚释放锁的线程下次继续获取该锁, 有可能导致其他线程永远获取不到锁, 造成"饥饿"现象.
- 公平锁为了保证时间上的绝对顺序, 需要频繁的切换上下文, 而非公平锁会降低一定的上下文切换, 降低性能开销. 因此, ReentrantLock默认选择的是非公平锁, 是为了减小一部分上下文切换, 保证了系统有更大的吞吐量.
独占锁和共享锁
- 独占锁: 只有一个线程持有线程同步状态(加锁)
- 共享锁: 多个线程可以持有线程同步状态
4. Semaphore/CountDownLatch
// 使用join等待
@Test
public void t1() throws InterruptedException {
Thread[] threads = new Thread[20];
for (int i = 0; i < 20; i++) {
final int j = i;
threads[i] = new Thread(() -> {
System.out.println(j);
});
threads[i].start();
}
for (int i = 0; i < 20; i++) {
threads[i].join();
}
System.out.println("main");
}
// 使用CountDownLatch进行等待操作
@Test
public void t2() throws InterruptedException {
CountDownLatch cdl = new CountDownLatch(20);//定义初始值为20的计数器
for (int i = 0; i < 20; i++) {
final int j = i;
new Thread(() -> {
System.out.println(j);
cdl.countDown();//数量--操作
}).start();
}
cdl.await();//数量等于0, 往下执行, 否则当前线程阻塞等待countDown()方法--
System.out.printf("main");
}
// 使用Semaphore进行等待操作
@Test
public void t3() throws InterruptedException {
Semaphore s = new Semaphore(0);//定义许可证数量为0
for (int i = 0; i < 20; i++) {
final int j = i;
new Thread(() -> {
System.out.println(j);
s.release();//许可证数量++, 也可以指定增加的数量
}).start();
}
// 获取指定数量的许可证, Semaphore中许可证的数量减去acquire的数量,
// 如果获取不到这么多数量的许可证(减去之后为负数), 当前线程会阻塞等待release()方法++
s.acquire(20);
System.out.printf("main");
}
/**
* 使用Semaphore进行有限的资源访问
* 模拟http请求的服务端:
* 假设同一个时间点, 只允许同时处理1000个并发的http请求
*/
@Test
public void t() throws InterruptedException {
Semaphore s = new Semaphore(1000);//定义许可证数量为0
for (;;) {
s.acquire();//许可证数量--, 如果许可证数量等于0, 就阻塞等待
new Thread(() -> {
try {
// 处理http请求的逻辑, 可能比较耗时
} finally {
s.release();
}
}).start();
}
}
ThreadLocal
1. 概念
- ThreadLocal用于提供线程局部变量, 在多线程环境中可以保证各个线程里的变量独立于其他线程的变量. 也就是说ThreadLocal可以为每个线程创建一个 单独的变量副本.
- 多个线程中使用ThreadLocal, 是操作自己线程独立的变量, 线程之间互不相关.
- ThreadLocal的作用与同步机制有点相反, 同步机制是为了保证多线程环境下数据的一致性; 而ThreadLocal是保证了多线程下数据的独立性.
public class Test {
private static String commStr;
private static ThreadLocal<String> threadStr = new ThreadLocal<String>();
public static void main(String[] args) {
commStr = "main";
threadStr.set("main");
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
commStr = "thread";
threadStr.set("thread");
System.out.println(threadStr.get());
}
});
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(commStr);
System.out.println(threadStr.get());
}
}
对于ThreadLocal类型的变量, 在一个线程中设置值, 不影响其在其他线程中的值. 也就是ThreadLocal类型的值在每个线程中是独立的.
2. 原理
Thread类中有一个ThreadHashMap的数据结构, 用来保存线程对象的变量
- get(), set(), remove()方法都会获取到当前线程, 然后通过当前线程获取到ThreadHashMap, 如果ThreadHashMap为null, 则会创建一个ThreadHashMap, 并给当前线程.
...
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
...
- 在使用ThreadLocal类型变量操作的时候, 都会通过当前线程获取到ThreadHashMap来操作. 每个线程的ThreadHashMap是属于线程自己的, ThreadLocalMap中维持的值也是属于线程自己的.这就保证了ThreadLocal类型的变量在每个线程中是独立的, 在多线程下不会互相影响
ConcurrentHashMap(重点)
面试题:
- ConcurrentHashMap的读是否要加锁,为什么?
- ConcurrentHashMap的锁分段技术?
- ConcurrentHashMap的迭代器是强一致性的迭代器还是弱一致性的迭代器?
- Mashmap和ConcurrentHashMap怎么确定key的唯一性?
- HashTable和HashMap、ConcurrentHashMap?
- ConcurrentHashMap的原理使用?
- ConcurrentHashMap在jdk1.8做了哪些优化?
- ConcurrentHashMap如何实现线程安全?
ConcurrentHashMap简介
基本特点
- 和HashMap功能基本一致, 只要是解决了HashMap的线程不安全问题
- JDK1.7的基本设计理念就是切分成多个Segment块, 默认是16个, 也就是说并发度是16, 可以在初始化时显式指定, 后期不能修改, 每个Segment里面可以近似看成是一个HashMap, 每个Segment快都有自己独立的ReentrantLock锁, 所以并发操作时, 每个Segment互不影响; 在JDK1.8中, 将Segment块换成了Node, 每个Node有自己的锁, 即每个Node都有自己的并发度.
- 不允许空值和空键, 否则会抛出异常.
JDK1.7下
- 底层数据结构也是数组+链表.HashEntry是链表的结点
- 采用了segment分段锁技术, 在多线程并发更新操作的时候, 对同一个segment进行同步加锁, 保证数据的安全. 这样就可以基于不同的segment进行并发写操作.
- 同步的实现方式是基于ReentrantLock锁机制(Segment继承自ReentrantLock)
- 和HashMap一样, 同样存在hash冲突时, 链表查询效率低的问题.
JDK1.8下
- 底层数据结构与HashMap1.8一样, 都是基于数组+链表+红黑树
- 支持多线程的并发操作, 实现原理是: CAS + Synchronized保证线程安全
- put方法存放元素时: 通过key对象的hashcode计算出数组的索引, 如果没有Node, 则使用CAS尝试插入元素, 失败则无条件自旋知道插入成功; 如果存在Node, 则使用synchronized锁住该Node元素(链表/红黑树的头结点), 在执行插入操作.
1.7和1.8版本都存在的特性
- 键和值迭代器为弱一致性迭代器, 创建迭代器后, 可以对元素更新
- 读操作没有加锁,因为value都是volatile修饰的, 保证了可见性, 所以是安全的.
- 读写分离可以提高效率: 多线程对于不同的Node/Segment的插入/删除是可以并发并行执行的, 对于同一个Node/Segment的写操作是互斥的. 读操作都是无锁操作, 可以并发,并行执行.
1.7到1.8, ConcurrentHashMap的变化
- 锁方面: 由分段锁(Segment继承自ReentrantLock)升级为CAS+Synchronized实现
- 数据结构方面: 将Segment换成了Node, 每个Node独立, 原来默认的并发度为16, 变成了每个Node都独立, 提高了并发度.
- hash冲突: 1.7发生hash冲突用链表存储, 1.8先使用链表存储, 后面满足条件后(如果添加元素的链表节点个数超过8)转为红黑树优化查询.
HashMap, HashTable, ConcurrentHashMap的区别
- HashMap允许键和值为null, HashTable和ConcurrentHashMap不允许
- HashMap不保证线程安全, HashTable和ConcurrentHashMap是线程安全的
- 对于效率问题:
♦ 因为HashMap不保证线程安全, 所以效率最高, 适合用于单线程场景;
♦ 而HashTable是使用synchronized修饰方法实现线程安全, 锁住整个HashTable, 效率非常低;
♦ ConcurrentHashMap则是使用CAS+synchronized实现线程安全( put方法存放元素时: 通过key对象的hashcode计算出数组的索引, 如果没有Node, 则使用CAS尝试插入元素, 失败则无条件自旋知道插入成功; 如果存在Node, 则使用synchronized锁住该Node元素(链表/红黑树的头结点), 在执行插入操作.)所以效率比HashTable高, ConcurrentHashMap已经可以完全取代HashTable. - 对于扩容机制
♦ HashTable初识size为11, 扩容: newsize=oldsize2+1
♦ HashMap和ConcurrentHashMap初识size都是16, 扩容为newsize=oldsize2, size一定为2的n次幂.