Java并发编程中的核心要点

Synchronized 的原理

Synchronized 修饰方法块

JVM 对方法块同步是通过 monitorentermonitorexit 两个比较重要的指令来实现的。任何对象都有一个 monitor(这里 monitor 指的就是锁) 与之关联,当且仅当一个 monitor 被持有后,它将处于锁定状态。

Synchronized 修饰方法

JVM 通过 ACC_SYNCHRONIZED 标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor 对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

注意 :wait/notify 等方法也依赖于 monitor 对象,这就是为什么只有在同步的块或者方法中才能调用 wait/notify 等方法,否则会抛出java.lang.IllegalMonitorStateException 的异常的原因。

监视器锁(monitor)本质是依赖于底层的操作系统的 Mutex Lock 来实现的。操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为“重量级锁”。

锁的公平性

公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该和锁的请求顺序一致,也就是FIFO。

非公平锁可能使线程“饥饿”,ReentrantLock 中默认被设定为非公平的实现。原因是非公平性锁模式下线程上下文切换的次数少,因此其性能开销更小。公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量

Synchronized是非公平锁。 Synchronized在线程进入ContentionList 时,等待的线程会先尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。

偏向锁、轻量级锁、重量级锁

synchronized 会导致竞争不到锁的线程进入阻塞状态,所以说它是 Java 语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM 从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。

轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;

轻量级锁膨胀为重量级锁的条件:

  1. 轻量级锁加锁时:只有作为锁的对象处于未被锁定并且CAS成功或者锁被当前线程持有(可重入)时才可以进入同步块,否则轻量级锁都膨胀为重量级锁;
  2. 轻量级锁解锁时:只有当前线程是锁定拥有者并且CAS成功时才可以正常释放锁,退出,否则轻量级锁都膨胀为重量级锁。注意:等待轻量锁的线程不会阻塞,它会一直自旋等待锁

上面几种锁都是 JVM 自己内部实现,当我们执行 synchronized 同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;

在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,如果以上两种都失败,则启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,使线程阻塞挂起;如果线程争用激烈,那么应该禁用偏向锁(撤销偏向锁的时候会导致 stop the word)。

CAS 操作

Java 实现原子操作的方式:锁和循环 CAS(Compare and Swap 比较并交换);CAS 利用了处理器的 CMPXCHG 指令(该指令是原子的)。

除了偏向锁,JVM 实现锁的方式都用了循环 CAS,即当一个线程想进入同步块的时候使用循环 CAS 的方式来获取锁,当它退出同步块的时候使用循环 CAS 释放锁。

锁的可重入性

锁的可重入性,表示该锁能够支持一个线程对资源的重复加锁

Java 中 提供了 ReentrantLock 这种可重入的锁。

synchronized 关键字隐式地支持重入,比如一个 synchronized 修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁,而不像Mutex` 由于获取了锁,而在下一次获取锁时出现阻塞自己的情况。

等待/通知机制

1
2
3
4
5
6
7
8
9
10
11
synchronized(obj) {
while(条件不满足) {
obj.wait();
}
处理逻辑;
}

synchronized(obj) {
改变条件;
obj.notifyAll();
}

while 循环中判断条件并调用 wait() 是使用 wait() 的唯一正确方式:这样能保证线程在睡眠前后都会检查条件。

if 为什么错了呢?

1
2
3
4
5
6
synchronized(obj) {
if (条件不满足) {
obj.wait();
}
处理逻辑;
}

wait() 的线程被其他线程用 notify()notifyAll() 唤醒后,是需要先获得锁的(毕竟你是在 synchronized 块里);如果在被唤醒到获得锁的这段时间内,条件又被另一个线程改变了,而你获得锁并从wait()方法返回后,直接跳出了 if 的条件判断——这时条件是不满足的,于是产生了逻辑错误。所以,线程在睡眠前后都需要检查条件。

Java 里的原子操作

处理器实现原子操作的方式:总线锁(锁住整个内存);缓存锁(在处理器内部缓存中实现原子操作,使其他处理器不能缓存 i 的缓存行)。

Java 实现原子操作的方式:锁和循环 CAS(Compare and Swap 比较并交换);CAS 利用了处理器的 CMPXCHG 指令(该指令是原子的)。

volatile、final 和 锁的内存语义

锁的内存语义
  • 当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中
  • 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。
Volatile的内存语义
  • 当写一个volatile变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存;
  • 当读一个volatile变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

volatile 的内存语义保证了其修饰的变量操作不能被重排序:

  1. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后
  2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

总结:锁释放与 volatile 写有相同的内存语义;锁获取与 volatile 读有相同的内存语义。

final的内存语义
  • JMM禁止编译器把final域的写重排序到构造函数之外(对普通域的写可能被重排序到构造函数之外!);
  • 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。

结束正在运行的线程

使用标志位
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MyThread extends Thread{

private volatile boolean isCancelled;

@Override
public void run(){
while(!isCancelled){
//do something
}
}

public void cancel(){
isCancelled=true;
}
}

注意的是,isCancelled 需要为 volatile,保证线程读取时isCancelled 是最新数据。

捕获线程中断
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MyThread2 extends Thread {

@Override
public void run() {
while(!Thread.currentThread().isInterrupted()) {
// 处理逻辑
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 设置中断状态
}
}
}

public void cancel(){
interrupt();
}
}

需要注意的是一些阻塞方法像 sleep() 等会清除中断标志,所以在 catch 的时候需要重新设置一下中断标志。

一句话知识点

  1. 设置线程优先级时,针对频繁阻塞(休眠或IO操作)的线程需要设置较高的优先级,而偏重计算的线程则设置较低的优先级,确保处理器不会被独占。
---(完)---
Yves wechat
扫一扫互相关注吧~

扫一扫关注公众号