本文将会介绍Java多线程中的重点知识,本文内容参考了网上的资料整理,主要为了自己看着方便,方便查找。
主要来源有:
- Guide哥
- 小林Coding
- 菜鸟教程
锁策略是操作系统上的一个重要策略,不仅仅局限于Java。
Synchronized 最开始使用的就是乐观锁策略。当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略。
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突。我们可以引入一个 “版本号” 来解决。
使用版本号,对每一次修改都记录在案,只要发生修改则版本号+1。提交的版本必须大于当前版本才能执行更新。
悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。
锁的最低层就是互斥锁和自旋锁,很多高级的锁都是基于它俩实现的。
加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
**对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。**当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。
互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。
那这个开销成本是什么呢?会有两次线程上下文切换的成本:
综上,如果被锁住的代码执行时间很短,那么就不应该使用互斥锁,应该选择自旋锁。
自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
一般加锁的过程,包含两个步骤:
自旋锁是一种典型的轻量级锁的实现方式:
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的
锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的.
加锁机制重度依赖了 OS 提供了 mutex
加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex。
读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。
读写锁适用于能明确区分读操作和写操作的场景。
读写锁的工作原理是:
所以,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。
综上,读写锁非常适合读多写少的场景
读写锁就是把读操作和写操作区分对待。Java 标准库提供了 ReentrantReadWriteLock
类, 实现了读写
锁。
ReentrantReadWriteLock.ReadLock
类表示一个读锁。 这个对象提供了 lock/unlock
方法进行ReentrantReadWriteLock.WriteLock
类表示一个写锁。 这个对象也提供了 lock/unlock
方法进公平锁: 遵守 “先来后到”. B 比 C 先来的。 当 A 释放锁的之后, B 就能先于 C 获取到锁。
非公平锁: 不遵守 “先来后到”。 B 和 C 都有可能获取到锁。
操作系统内部的线程调度就可以视为是随机的。如果不做任何额外的限制, 锁就是非公平锁。如果要
想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序。
**注:**公平锁和非公平锁没有好坏之分, 关键还是看适用场景。Synchronized 是非公平锁。
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入
锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括
synchronized关键字锁都是可重入的。
Linux 系统提供的 mutex 是不可重入锁。
注:Synchronized 是可重入锁。
CAS: 全称Compare and swap,字面意思:”比较并交换“。一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
CAS的操作是一个原子的硬件执行完成的。
标准库中提供了 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
自旋锁伪代码
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;
}
}
假设存在两个线程t1和t2。有一个共享变量num。初始值为A。
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要
全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比
较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑。
给要修改的数据引入版本号。在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。
如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当
前版本号比之前读到的版本号大, 就认为操作失败。
了解了锁之后,了解一下Synchronized关键字的原理。
第一个尝试加锁的线程, 优先进入偏向锁状态。
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁)。
此处的轻量级锁就是通过 CAS 来实现。
注: 此处的自旋不会一直持续进行,一直让 CPU 空转, 比较浪费 CPU 资源。因此达到一定的时间/重试次数, 就不再自旋了。也就是所谓的 “自适应”。
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会转变为重量级锁。
此处的重量级锁就是指用到内核提供的 mutex 。
通过编译器+JVM 判断锁是否可消除。如果可以, 就直接消除。
有些程序代码中用到了 synchronized关键字,但是在单线程情况下就会进行锁消除,减少没用的资源开销。
示例:
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化。
使用细粒度锁, 是期望释放锁的时候其他线程能使用锁。但是实际上可能并没有其他线程来抢占这个锁。 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁。
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全
ReentrantLock 的用法:
方法 | 作用 |
---|---|
lock() |
加锁, 如果获取不到锁就死等 |
trylock(超时时间) |
加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁 |
unlock() |
解锁 |
如何选择使用哪个锁?
Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其
他线程干扰。所以,所谓原子类说简单点就是具有原子/原子操作特征的类。
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。
并发包 java.util.concurrent
的原子类都存放在java.util.concurrent.atomic
下:
根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类:
基本类型:
使用原子的方式更新基本类型
类 | 说明 |
---|---|
AtomicInteger |
整型原子类 |
AtomicLong |
长整型原子类 |
AtomicBoolean |
布尔型原子类 |
数组类型:
使用原子的方式更新数组里的某个元素
类 | 说明 |
---|---|
AtomicIntegerArray |
整型数组原子类 |
AtomicLongArray |
长整型数组原子类 |
AtomicReferenceArray |
引用类型数组原子类 |
引用类型
类 | 说明 |
---|---|
AtomicReference |
引用类型原子类 |
AtomicMarkableReference |
原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 |
AtomicStampedReference |
原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 |
对象的属性修改类型
类 | 说明 |
---|---|
AtomicIntegerFieldUpdater |
原子更新整型字段的更新器 |
AtomicLongFieldUpdater |
原子更新长整型字段的更新器 |
AtomicReferenceFieldUpdater |
原子更新引用类型里的字段 |
以 AtomicInteger 举例,常见方法有:
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
常用方法的使用:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicIntegerTest {
public static void main(String[] args) {
// TODO Auto-generated method stub
int temvalue = 0;
AtomicInteger i = new AtomicInteger(0);
temvalue = i.getAndSet(3);
System.out.println("temvalue:" + temvalue + "; i:" + i);//temvalue:0; i:3
temvalue = i.getAndIncrement();
System.out.println("temvalue:" + temvalue + "; i:" + i);//temvalue:3; i:4
temvalue = i.getAndAdd(5);
System.out.println("temvalue:" + temvalue + "; i:" + i);//temvalue:4; i:9
}
}
信号量,本质上就是一个计数器,用来表示 “可用资源的个数”。
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用。
代码示例
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();
}
同时等待 N 个任务执行结束。
public class Demo {
public static void main(String[] args) throws Exception {
CountDownLatch latch = new CountDownLatch(10);
Runnable r = new Runable() {
@Override
public void run() {
try {
Thread.sleep(Math.random() * 10000);
latch.countDown();
} catch (Exception e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 10; i++) {
new Thread(r).start();
}
// 必须等到 10 人全部回来
latch.await();
System.out.println("比赛结束");
}
}
原来的集合类, 大部分都不是线程安全的。
Collections.synchronizedList(new ArrayList)
优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争。
缺点:
方法 | 说明 |
---|---|
ArrayBlockingQueue |
基于数组实现的阻塞队列 |
LinkedBlockingQueue |
基于链表实现的阻塞队列 |
PriorityBlockingQueue |
基于堆实现的带优先级的阻塞队列 |
TransferQueue |
最多只包含一个元素的阻塞队列 |
在多线程环境下使用哈希表可以使用:
Hashtable
ConcurrentHashMap
把关键方法加上了 synchronized 关键字
相当于直接针对 Hashtable 对象本身加锁。
相比于 Hashtable 做出了一系列的改进和优化
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
有名的就是哲学家就餐问题等。
产生死锁的四个必要条件:
**注:**当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。
破坏死锁的产生的必要条件即可:
示例:
破坏循环等待。最常用的一种死锁阻止技术就是锁排序。假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号(1, 2, 3…M)。
N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁。这样就可以避免环路等待。
可能产生环路等待的代码:两个线程对于加锁的顺序没有约定, 就容易产生环路等待。
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock2) {
synchronized (lock1) {
// do something...
}
}
}
};
t2.start();
不会产生环路等待的代码:约定好先获取 lock1, 再获取 lock2 , 就不会环路等待。
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
};
t2.start();
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3…Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称
序列为安全序列。
synchronized, ReentrantLock, Semaphore 等都可以用于线程同步。
以 juc 的 ReentrantLock 为例,
使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待, 直到前面的线程执行了 V 操作。
读操作没有加锁. 目的是为了进一步降低锁冲突的概率. 为了保证读到刚修改的数据, 搭配了volatile 关键字。
Java1.7 中采取的技术. Java1.8 中已经不再使用了. 简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁。
目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争。
取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象)。
将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于 8 个元素)就转换成红黑树。