内存可见性

通常我们无法确保执行读操作的线程能适时的看到其他写线程写入的值,为了确保多线程间对内存写入操作的可见性,必须使用同步机制。

加锁与可见性

AB线程获取同一个锁,可以保证A中改变的变量,等到B获取锁后仍可以看见。

B线程执行由锁保护的同步代码块时,可以看见A线程之前在同一个同步代码块中的所有操作结果。

可见性即公共变量的可视性、一致性,同步保证并发下不同线程数据变量一致。对于变量来说,锁机制同步是为了确保某个线程写入改变量的值,对于其他线程来说是可见的。

加锁的意义不仅仅局限于互斥行为,还包括内存可见性,为了确保所有线程可以看到共享变量的最新值,所有执行读操作或写操作的线程都必须在同一个锁上同步。

Volatile

Java提供一种稍弱的同步机制,volatile关键字,用于确保将变量的更新操作通知到其他线程。

当变量声明为volatile后,编译器和运行时都会注意到该变量是共享的,因此不会将变量上的操作与其他内存操作一起重排序,volatile变量不会被缓存在寄存器或其他处理器不可见的地方,因此在读取volatile变量时总能得到最新写入的值。

volatile不会执行加锁操作,因此也不会使线程阻塞,volatile是一种比synchronized更轻量级的同步机制。

volatile也有一些不足,它不足以确保 i++ 这类操作的原子性,除非只有一个线程对变量执行写操作。

synchronized此类的加锁机制:原子性 + 可见性,volatile:可见性。

volatile主要作用是可见性 + 防重排,无加锁动作,不会阻塞线程,但是不能保证原子性。

取消与关闭

中断 interrpt 停止线程

java中断没有实质操作,一般是提供一个中断标识,程序根据标识进行处理,例如 wait、sleep 操作,底层会自动感知标识位抛出 InterruptedException 异常。

每个线程都有一个 boolean 类型的中断状态,中断操作会将此标识设置为 true,所以要真正中断一个运行中的线程,需要先触发中断修改状态,然后自行感知状态执行中断逻辑。

Thread 类提供一些相关方法:

  • interrupt():修改目标线程(连续多次中断,标识会一直为false,原因未知)
  • isInterrupted():感知线程中断标识
  • interrupted():清除中断标识位

JVM关闭

Hook

通过 Runtime.getRuntime.addShutDownHook() 注册钩子,JVM在关闭时会去调用钩子代码

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("钩子");
}));
System.out.println(1);
System.out.println(2);
System.exit(0);
System.out.println(3);
}

主要是看到了钩子 Hook 这个概念想提一下,Spring中提供了一些扩展点就是钩子,例如获取上下文会实现接口 ApplicationContextAware,我们可以获取上下文并进行相应扩展,需要对这个概念有一个认知。

守护线程

JVM关闭场景很多,普通的正常关闭方式是在最后一个普通线程(非守护)结束时被触发。程序中线程非为普通和守护,JVM启动时创建的所有线程中,除主线程外都是守护线程(如gc线程),当一个线程A创建新线程B时,B会继承创建者A的线程守护状态,默认情况下,程序中由主线程创建的全部都是普通线程。

普通、守护二者差异仅存在于线程退出时发生的操作,当一个线程退出时,JVM会检查其他正在运行的线程,若这些线程都是守护线程,则JVM会正常退出。

线程池

线程池大小

线程池过大,会创建大量线程,在CPU和内存资源上进行竞争,加大了内存使用量。

线程池过小,无法合理运用空闲的处理器,降低系统的吞吐率。

  • 计算密集任务:CPU数 + 1 可达到较好的利用率
  • IO密集任务 或 阻塞操作的任务:线程池规模更大,网传 2*CPU数
  • 线程池精确计算公式:CPU数 * CPU利用率 * (1 + 任务等待时间/任务计算时间)

ThreadPoolExecutor

1
线程池扭转过程:核心线程 -> 阻塞队列 -> 最大线程 -> 拒绝策略

阻塞队列

阻塞队列基本的三种实现:

  • 无界队列:任务无限积累知道上限
  • 有界队列:有利于避免资源耗尽,使用有界队列,其大小应与线程池大小一起调节
  • 同步移交:SynchronousQueue 可避免任务排队,直接将任务移交给工作线程,可以说不是队列而是一种线程间进行移交的机制,若元素放入该队列,没有线程等待任务,则会走后续最大线程、拒绝策略的逻辑,它不会进行存储任务。在无界线程池和可拒绝任务的场景下,它才有价值

拒绝策略

  • AbortPolicy:默认拒绝策略,会抛出 RejectedExecutionException ,我们可以捕获这个异常,执行自定义逻辑
  • DiscardPolicy:直接丢弃当前任务
  • DiscardOldestPolicy:丢弃下一个将要被执行的任务,FIFO 下一个要执行的就是最早入队列的任务,然后尝试重新提交新任务(不易和优先队列一起使用,因为被抛弃的是优先级最高的任务)
  • CallerRunsPolicy:让调用了 execute 的线程执行任务
  • 通过实现RejectedExecutionHandler进行自定义扩展

扩展性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ThreadPoolExecutor executor = new ThreadPoolExecutor(...) {
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
}

@Override
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
}

@Override
protected void terminated() {
super.terminated();
}
};
  • beforeExecute:扩展前置处理,出现异常,任务则不执行
  • afterExecute:扩展后置处理
  • terminated:所有任务完成且所有工作线程也关闭,terminated 会释放 Executor 在生命周期中分配的资源

减少锁的竞争

串行操作降低可伸缩性,而上下文切换回降低性能。在锁上发生竞争会同时导致这两个问题,因此减少锁的竞争能够提高性能急可伸缩性。(线程阻塞会频繁导致上下文切换)

缩小锁的范围

降低锁发生竞争的可能性,有效方法之一是减少锁的持有时间。例如,可将一些与锁无关的代码移出同步代码块,尤其是那些开销大的操作、以及可能被阻塞的操作,如IO操作。

细化加锁的代码范围,可以有效减少锁持有时间。而有时同步代码范围也不难过小,一些需要保证原子性的操作,必须包含在一个同步块中。

例:将一个同步方法改写成内部关键逻辑使用 synchronized 修饰,减少锁内部逻辑执行的时间。

减小锁的粒度——锁分解

另一种减少锁的持有时间的方式是降低线程请求锁的频率,进而减少发生竞争的可能性。

如一个锁需要保护多个相互独立的状态变量,你们可以将这个锁分解为多个锁,每个锁保护一个变量,降低每个锁被请求的频率,提高可伸缩性。

对竞争适中的锁进行分解时,实际上时将这些锁转变为非竞争锁,有效提高性能。而对竞争并不激烈的锁进行分解,则提升有限。

例:前提是一个类有多个独立的变量,且当前类存在多个同步方法,未进行锁分解前,所有方法都会针对当前实例获取锁,进行锁分解后,我们取消同步方法,改为在方法内部使用 synchronized 获取的锁则是各个逻辑对应的独立变量,这样所有方法锁的对象就细化了,减小了锁的粒度。

减小锁的粒度——锁分段

将一个竞争激烈的锁分解为两个锁,分解后的多个锁可能都存在激烈的竞争,在多处理器系统中,无法得到最大的性能提升,

某些情况下,可将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,即锁分段,细化作用域。

ConcurrentHashMap jdk1.7就是分段锁实现,现在jdk1.8已经换成 cas + synchronized,通过减少锁粒度,从1.7的分段到1.8的各个节点,进一步优化,相当于进一步分段了,具体后面我考虑去写一下ConcurrentHashMap的源码分析,到时候在关注细节。

例:存在一个map实例中,可以通过hash算法(通过hashcode设计)获取每个线程对应的映射,也就是将map中实例分段操作,每次锁的对象由一个map实例变为算法处理后各个线程对应的子模块,各个线程操作的区间不一样,减小锁粒度提高性能。

放弃独占锁

第三种降低锁竞争的方式就是放弃使用独占锁,才有非独占锁或非阻塞锁进行优化。

  • 读写锁 ReadWriteLock,读锁共享,写锁独占
  • 原子类,提供在整数、对象引用上的细粒度原子操作

显式锁

Lock与ReentrantLock

和syn同步代码块内置加锁机制不同,Lock接口提供了多种锁获取的方式,且所有加锁、解锁方法都是显式的。ReentrantLock 实现 Lock接口,并提供与 syn 相同的互斥性和内存可见性。获取 ReentrantLock 时,有着与进入同步代码块相同的内存语义,释放 ReentrantLock 时 ,同样有与退出同步代码块相同的内存语义。此外,ReentrantLock 和 synchronized 一样具有可重入的加锁语义。

至于为什么要创建一种和内置锁高度相似的锁机制,这是因为内置锁存在一定局限性。内置锁必须在获取该锁的代码块中释放,进入代码块获取锁,退出代码块释放锁,简化了编码工作,但却无法实现非阻塞结构的加锁规则。

通常我们使用Lock接口,需要 try-finally 格式,在finally中确保锁的释放。以下展示四种加锁机制:

  • 独占锁
  • 轮询锁,根据非阻塞加锁进行轮询重试
  • 定时锁,未超时期间为独占,被阻塞住可响应中断
  • 可感知中断的独占锁
  • 非块结构的加锁,显示锁的lock可以应对非块结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 独占锁,阻塞
lock.lock();

// 配合循环一直重试组成轮询锁,非阻塞加锁,有返回值
boolean b = lock.tryLock();

// 定时锁,超时时间内是独占锁,阻塞,超时后变为非阻塞,有返回值。且阻塞时可以响应中断
try {
boolean b1 = lock.tryLock(1, TimeUnit.DAYS);
} catch (InterruptedException e) {
e.printStackTrace();
}

// 感知中断的独占锁
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
e.printStackTrace();
}

// syn同步代码块,块结构加锁
synchronized (lock) {
doSomthing();
}

公平性

ReentrantLock 有公平锁、非公平锁两种实现,公平锁线程按照发出请求的顺序获得锁,而非公平锁允许插队,即某线程请求非公平锁时,若发出请求的同时该锁的状态变为可用,则跳过队列中所有的等待线程并获得该锁。

普通场景我们都是使用默认实现,即非公平锁提高吞吐量,有需要使用公平锁的特殊场景则另外讨论。

synchronized、ReentrantLock 的选择

为什么有了 ReentrantLock 我们还在用 synchronized?主要是 synchronized 可以自动释放锁,没有了ReentrantLock 忘记释放锁的隐患,且 synchronized 已经被广泛使用也能满足普通需求。而 ReentrantLock 主要是适用于内置锁无法满足需求的情况下,充当一种高级工具,提供一些高阶功能,我们普通加锁使用 synchronized 就够了。

此外 synchronized 相比 ReentrantLock 还有一个优点:线程转储中能给出在哪些调用帧中获得了哪些锁,并可以检测和识别发生死锁的线程。而 ReentrantLock 想实现这个功能需要自行实现接口,ReentrantLock 的非块结构特性意味着获取锁的操作不能与特定的栈帧关联起来,而 synchronized 可以。

ReentrantReadWriteLock 读写锁

读写锁放宽加锁需求,允许多个读线程同时访问,可以提升系统性能。只要每个线程都能确保读取到最新数据,且读取数据时不会有其他线程修改数据,就不会发生问题,基于此种情况使用读写锁,读锁可被多个线程持有,写锁只能被单一线程持有。

在多处理器系统上被频繁读取的数据结构,读写锁能提高性能,而其他情况下读写锁因为复杂性高于独占锁,性能会略差一点。基于实际场景使用。

多线程读写锁,读读共享、读写互斥、写读互斥、写写互斥,但是内部加锁操作有所不同。

可重入性

读写锁,读锁、写锁都是可重入的,写锁内部加写锁不会死锁。

锁降级

当一个线程持有写锁,当它未释放写锁的情况下获取读锁,这会使写锁降级为读锁,同时不允许其他写线程修改被保护的资源,锁降级是为了保证线程的可见性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class LockDegrade {
int i = 0;

ReadWriteLock lock = new ReentrantReadWriteLock();
Lock writeLock = lock.writeLock();
Lock readLock = lock.readLock();

public static void main(String[] args) {
LockDegrade degrade = new LockDegrade();
new Thread(degrade::doSomething).start();
new Thread(degrade::doSomething).start();
}

// public void doSomething () {
// writeLock.lock();
//
// ++i;
// try { Thread.sleep(1000); } catch (InterruptedException e) { }
// System.out.println(i);
//
// writeLock.unlock();
// }
//
// public void doSomething () {
// writeLock.lock();
// ++i;
// writeLock.unlock();
//
// try { Thread.sleep(1000); } catch (InterruptedException e) { }
// System.out.println(i);
// }

public void doSomething () {
writeLock.lock();
++i;
readLock.lock();

writeLock.unlock();
try { Thread.sleep(1000); } catch (InterruptedException e) { }
System.out.println(i);
readLock.unlock();
}
}

针对上面这个demo我们来分析一下锁降级,3个doSomething的写法逐个分析

  • 第一种写法是通过写锁互斥,让每个线程都能读到自己修改后的变量值,假定业务有优化空间,只有数据修改这一步是并发的,那么我们可以相应的减小锁的范围,提高性能。
  • 第二种写法是我们为了减小锁的范围,只将数据修改包裹在写锁内,后续释放锁根据数据走业务逻辑,但此时可能会出现问题,当线程A写锁释放后,线程B就可以拿到写锁进行数据修改操作,此时如果线程A后续业务读取数据的操作还未执行,那么线程A的变量值会变为线程B修改后的值,此时当业务再来读取,就产生了一致性问题。面对第二种写法的问题就有锁降级这种处理方式。
  • 第三种写法就是锁降级,我们在执行完数据修改操作后,继续在线程内部获取读锁,接着我们在释放写锁,此时写锁被释放肯定会有其他线程来竞争,但是我们加了读锁,由于读写互斥的原因,其他写线程获取不到写锁,随后我们执行业务操作就能得到当前线程修改的变量值,最后释放掉读锁其他写线程又可以来竞争了。使用锁降级和第一种纯写锁包裹的区别,就是读数据这一步是共享的,性能可能比写锁包裹高一点,这一点算是我的猜想,这里的示例也是为了讲清楚锁降级到底是干啥,能否应用到实际项目我们需要结合实际场景讨论。

为什么没有锁升级(附加写锁、读锁源码浅析)

既然可以将写锁降级为读锁,保证一致性,那么有没有可能将读锁升级为写锁呢,答案是不可以。

以下为网络答案:读锁升级死锁这种写法会导致死锁,因为读锁可以多线程同时持有,写锁只能单一线程持有,读锁升级需要其他线程的读锁都释放掉才可以,所以每个持有读锁的线程都是等待其他线程释放读锁,然后再去获取写锁,这个互相等待读锁释放的过程就是一个明显的死锁,所以永远获取不到写锁,无法锁升级。

看了网上形形色色的解析,没有一个基于code证明说法的,今天不信邪来看看源码是怎么写的。以下是我对写锁、读锁源码的粗略分析。

  • 写锁分析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 写锁加锁方法
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
// 线程获取锁的标识,可以当作数量,获取写锁+1,获取读锁+65536,
int c = getState();
// 写锁个数,除重入锁的情况下,这个计数永远不会>1,可认为是写锁的可重入数,具体实现看后面代码
// 加读锁,不影响写锁的可重入数,读锁重入锁是另一种计算方式,底层通过位运算实现
int w = exclusiveCount(c);
// state不为0,说明有线程获取了读锁或写锁
if (c != 0) {
// 1、可重入数==0,true:有线程获取了读锁,false:有线程获取了写锁
// 2、有线程获取了写锁,判断当前线程是不是独占线程,
// true:说明不是当前线程获取读锁,false:当前线程获取了写锁
if (w == 0 || current != getExclusiveOwnerThread())
// 1、只要有线程获取了读锁,加写锁就失败,2、其他线程获取了写锁,写写互斥,加写锁失败
return false;
// 判断写锁的可重入数在这次加锁后是否超过上线,超过则抛error
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 重入锁,对标识位++,这里可以发现重入锁是直接操作标识位,没有通过cas操作
setState(c + acquires);
return true;
}
// 1、写锁是否应该阻塞,非公平锁=false,公平锁会进阻塞队列判断节点
// 2、通过校验,cas加锁并取反结果,写锁state+=1,false:加锁成功,true:枷锁失败
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
// 写锁被公平锁阻塞队列阻塞;或cas加锁失败
return false;
// cas加锁成功将当前线程设置为独占线程
setExclusiveOwnerThread(current);
return true;
}

// 可重入锁的上限,超过会报error,自己可以new个写锁跑循环试试
// 这个上限设计和加读锁,state+65536这些设计都是挂钩的,jdk位运算玩的6
for (int i = 0; i < 65536; i++){
writeLock.lock();
}
  • 读锁分析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 读锁加锁方法,写锁中出现过的简单注释就不重复了
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// 1、可重入数!=0,true:有线程获取了写锁,false:有线程获取了读锁,2、见写锁中判断注释
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
// 有其他线程获取了写锁,写读互斥加读锁失败
return -1;
// 读锁的可重入数
int r = sharedCount(c);
// 1、读锁是否应该阻塞,这里面有多个极端场景的校验,我们暂不讨论,2、不超过可重入上限
// 3、cas加锁,这里可以发现读锁加锁state+=SHARED_UNIT,去源码看是位运算的65536
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
if (r == 0) {
// 没有可重入数,标记为第一次加读锁的线程,count=1
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
// 有可重入数,且当前线程是第一次加读锁的线程,直接对第一次Reader的变量++
firstReaderHoldCount++;
} else {
// 有可重入数,且不是第一次加读锁的线程,拿缓存
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
// 1、缓存拿不到,说明是头一次获取读锁的线程,
// 2、缓存tid与当前线程不匹配,从本地ThreadLocal获取,此处设计我不知道用意
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
// 缓存拿到且是当前线程,而计数为0,则放到本地ThreadLocal
readHolds.set(rh);
// 缓存计数++
rh.count++;
}
return 1;
}
// 读锁被校验阻塞 或 加锁失败继续走逻辑,以后做AQS全解我们在慢慢理,现在就不进一步了
return fullTryAcquireShared(current);
}

总结一下:

  • 加写锁时,无论是当前线程还是其他线程,只要有线程持有读锁就加写锁失败,这里也就解释了为什么锁升级会失败,必须等读锁全部释放才能加锁,网上的结论确实是对的,至于原因如果我不分析永远都不知道了,就没看到一篇文章对着代码分析过。。。
  • 加写锁,若其他线程持有写锁,写写互斥,加锁失败
  • 加写锁,若当前线程持有写锁,可重入性,可以加锁
  • 加读锁,若其他线程持有写锁,写读互斥,加锁失败
  • 加读锁,除了其他线程持有写锁的情况都能触发加锁逻辑,所以当前线程持有写锁不影响其获取读锁,这也就是锁降级可行的原因,而且读读也不影响,读读共享得到验证

Condition(类比wait)

1
2
3
Condition condition = Lock.newCondition();
condition.await();
condition.signal();

对于每个Lock,可以有任意数量的Condition对象,可使用Condition对显示锁进行等待、唤醒操作,而不是Object的wait、notify,当然和内置锁一样,显示锁使用Condition进行等待、唤醒前需要先获取锁。

ps:在Condition对象中,对应wait、notify、notifyAll的分别是await、signal、signalAll,但是Condition对Object进行了扩展,它也包含了wait、notify,我们一定要使用正确的版本——await、signal

CAS

compareAndSwap 比较并设置,无论操作是否成功都有返回,CAS包含了三个操作数,需要读写的内存位置V、进行比较的值A、拟写入的新值B。当且仅当V的值等于A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。

当多线程尝试使用CAS同时更新一个变量时,只有其中一个线程能更新变量值,而其他线程都将失败,失败线程不会被挂起,而是告知竞争失败。CAS可实现原子性的读—改—写操作。

ABA

当数据A被两个线程X、Y都同时读到,Y线程先处理其他业务,而X则进行两次CAS操作,数据变化为 A -> B,B -> A,最后等Y处理完业务进行CAS操作时,看到的数据仍然是A,误以为没有改变,成功执行了CAS,其实在实际操作中该数据是被改变了,这种中间状态的不可查情况即是ABA问题。

对于触发这种问题的场景,常规解决方案是引入第三方参数进行辅助校验,例如版本号机制,每次操作数据则版本号递增,即使多个线程读取数据时值一样,但轮到每个线程操作时版本号不一致,说明中间状态被修改过,CAS执行失败。

一般CAS操作是在Unsafe类中实现,针对int、long、object三种类型有三种实现compareAndSwapIntcompareAndSwapLongcompareAndSwapObject,原子类中会分别基于这三种原生方法进行封装,而解决ABA问题就有两个原子类可以实现,AtomicStampedReferenceAtomicMarkableReference,第一个是类似于版本号的实现,第二个是通过true false进行辅助标记。