synchronized

最常见的锁,可以非常快速的实现多线程的同步操作,只需要在需要同步的方法对象、或代码块中加入该关键字,就能保住同一时刻最多只有一个线程执行

使用synchronized修饰的代码具有原子性和可见性,在需要进程同步的程序中使用的频率非常高,可以满足一般的进程同步要求

Java实现的锁机制有很多种,并且有些锁机制性能也比synchronized高,但还是强烈推荐在多线程应用程序中使用该关键字,因为实现方便,后续工作由JVM来完成,可靠性高。只有在确定锁机制是当前多线程程序的性能瓶颈时,才考虑使用其他机制,如ReentrantLock等。

原理

从语法上讲,Synchronized可以把任何一个非null对象作为”锁”,在HotSpot中,锁有个专门的名字:对象监视器(Object Monitor)

锁的对象:

  1. 当synchronized作用在实例方法时,监视器锁(monitor)便是对象实例(this);
  2. 当synchronized作用在静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;
  3. 当synchronized作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例;

数据同步需要依赖锁,那锁的同步又依赖谁?synchronized给出的答案是在软件层面依赖JVM

当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:

1
2
3
4
5
public void test() {
synchronized (this) {
System.out.println("Hello World");
}
}

查看反编译的结果

1
2
3
4
5
6
7
8
9
10
11
12
;省略
monitorenter
getstatic #7 <java/lang/System.out : Ljava/io/PrintStream;>
ldc #13 <Hello World>
invokevirtual #15 <java/io/PrintStream.println : (Ljava/lang/String;)V>
aload_1
monitorexit
goto 22 (+8)
astore_2
aload_1
monitorexit
;省略
  1. monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

    1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
    2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
    3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
  2. monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

    monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;

通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

ReentrantLock

可重入互斥锁,其基本行为和语义与使用同步方法和语句访问的隐式监视器锁相同,但具有扩展功能。

ReentrantLock属于上次成功锁定但尚未解锁的线程。当锁不属于另一个线程时,调用锁的线程将返回并成功获取锁。如果当前线程已经拥有锁,则该方法将立即返回。

可以使用isHeldByCurrentThreadgetHoldCount方法进行检查。

此类的构造函数接受可选的公平性参数。如果设置为true,则在争用情况下,锁有利于授予对等待时间最长的线程的访问权。否则,此锁不保证任何特定的访问顺序。

与使用默认设置的程序相比,使用由多个线程访问的公平锁的程序可能会显示较低的总体吞吐量,但在获得锁和保证无饥饿的时间上差异较小。然而,请注意,锁的公平性并不能保证线程调度的公平性。因此,使用公平锁的多个线程中的一个线程可能会连续多次获得公平锁,而其他活动线程则没有进展,当前也没有持有该锁。还要注意,tryLock()方法不支持公平性设置。如果锁可用,即使其他线程正在等待,它也会成功。

建议在调用后立即使用try块锁定,最典型的是在构建之前/之后,例如:

1
2
3
4
5
6
7
8
9
10
11
class X {
private final ReentrantLock lock = new ReentrantLock();
public void m() {
lock.lock();
try {
// ... method body
} finally {
lock.unlock();
}
}
}

需要额外注意的是:此类的序列化与内置锁的行为相同:反序列化的锁处于解锁状态,而不管其序列化时的状态如何。

最多支持同一线程的2147483647个递归锁。试图超过此限制会导致锁定方法引发错误。

Semaphore

计数型信号量。从概念上讲,信号量维护一组许可。如有必要,每个模块都会阻塞,直到获得许可证,然后再获取。

每次发布都会添加一个许可证,可能会释放一个阻塞收单机构。

但是,未使用实际许可对象;信号量只保留可用数字的计数,并相应地进行操作。

信号量通常用于限制可以访问某些(物理或逻辑)资源的线程数。

例如,下面是一个类,它使用信号量控制对资源的访问:

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
class Pool {
private static final int MAX_AVAILABLE = 100;
private final Semaphore available = new Semaphore(MAX_AVAILABLE, true);

public Object getItem() throws InterruptedException {
available.acquire();
return getNextAvailableItem();
}

public void putItem(Object x) {
if (markAsUnused(x)) available.release();
} // Not a particularly efficient data structure; just for demo

protected Object[] items = ...; // whatever kinds of items being managed
protected boolean[] used = new boolean[MAX_AVAILABLE];

protected synchronized Object getNextAvailableItem() {
for (int i = 0; i < MAX_AVAILABLE; ++i) {
if (!used[i]) {
used[i] = true;
return items[i];
}
}
return null; // not reached
}

protected synchronized boolean markAsUnused(Object item) {
for (int i = 0; i < MAX_AVAILABLE; ++i) {
if (item == items[i]) {
if (used[i]) {
used[i] = false;
return true;
} else return false;
}
}
return false;
}
}

在获取项目之前,每个线程必须从信号量获取许可证,以确保项目可用。

当线程处理完该项后,它将返回到池中,并向信号量返回一个许可证,允许另一个线程获取该项。

调用acquire时不会保持同步锁,因为这会阻止项目返回到池中。

信号量封装了限制对池的访问所需的同步,与维护池本身一致性所需的任何同步分开。

一个初始化为1的信号量,其使用方式是它最多只有一个可用的许可证,可以用作互斥锁。这通常被称为二进制信号量,因为它只有两种状态:一个许可可用,或者零个许可可用。

以这种方式使用时,二进制信号量具有这样的属性(与许多java.util.concurrent.locks.Lock实现不同),即“锁”可以由所有者以外的线程释放(因为信号量没有所有权的概念)。

这在某些特定的上下文中很有用,例如死锁恢复。

此类的构造函数可以选择接受公平性参数。当设置为false时,此类不保证线程获取许可的顺序。

特别是,允许bargging,也就是说,调用acquire的线程可以在等待的线程之前分配一个许可证:

从逻辑上讲,新线程将自己置于等待线程队列的头部。

当公平性设置为true时,信号量保证选择调用任何acquire方法的线程,以按照其调用这些方法的处理顺序(FIFO)获取许可。

FIFO排序必然适用于这些方法中的特定内部执行点。

因此,一个线程可以在另一个线程之前调用acquire,但可以在另一个线程之后到达排序点,类似地,从方法返回时也可以到达排序点。还要注意,tryAcquire方法不支持公平性设置,但将接受任何可用的许可。

通常,用于控制资源访问的信号量应该初始化为公平的,以确保没有线程因访问资源而耗尽。

当将信号量用于其他类型的同步控制时,非公平排序的吞吐量优势往往超过公平性考虑。

此类还提供了方便的方法,可以一次获取和发布多个信号量。这些方法通常比循环更有效。

但是,它们不建立任何优先顺序。例如,如果线程A调用s.acquire(3),线程B调用s.acquire(2),并且2个信号量可用,那么不能保证线程B将获得它们,除非它的acquire先到达,并且信号量s处于公平模式。

为避免线程因抛出异常而无法正常释放锁的情况发生,释放锁的操作必须在finally代码块中完成

Atomic

原子类,通过JUC提供的Atomic[Integer/Boolean/…],提供了所有类型的原子操作

除了简单的setget外,还包括getAndSet, compareAndSet, getAndIncrement等操作

通过Unsafe使这些方法都实现了原子性

补充

公平锁、非公平锁

公平锁指多个线程按照申请锁的顺序来获取锁,非公平锁就是没有顺序完全随机,所以能会造成优先级反转或者饥饿现象;**synchronized 就是非公平锁,ReentrantLock(使用 CAS 和 AQS 实现) 通过构造参数可以决定是非公平锁还是公平锁,默认构造是非公平锁;非公平锁的吞吐量性能比公平锁大好。**

可重入锁

又名递归锁,指在同一个线程在外层方法获取锁的时候在进入内层方法会自动获取锁,synchronized ReentrantLock 都是可重入锁,可重入锁可以在一定程度避免死锁。

独享锁、共享锁

独享锁是指该锁一次只能被一个线程持有,共享锁指该锁可以被多个线程持有;**synchronized ReentrantLock 都是独享锁,ReadWriteLock 的读锁是共享锁,写锁是独占锁;**ReentrantLock 的独享锁和共享锁也是通过 AQS 来实现的。

互斥锁、读写锁

其实就是独享锁、共享锁的具体说法;互斥锁实质就是 ReentrantLock,读写锁实质就是 ReadWriteLock

乐观锁、悲观锁

这个分类不是具体锁的分类,而是看待并发同步的角度;悲观锁认为对于同一个数据的并发操作一定是会发生修改的(哪怕实质没修改也认为会修改),因此对于同一个数据的并发操作,悲观锁采取加锁的形式,因为悲观锁认为不加锁的操作一定有问题;乐观锁则认为对于同一个数据的并发操作是不会发生修改的,在更新数据的时候会采用不断的尝试更新,乐观锁认为不加锁的并发操作是没事的;由此可以看出悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升,悲观锁在 java 中很常见,乐观锁其实就是基于 CAS 的无锁编程,譬如 java 的原子类就是通过 CAS 自旋实现的。

分段锁

实质是一种锁的设计策略,不是具体的锁,对于 ConcurrentHashMap 而言其并发的实现就是通过分段锁的形式来实现高效并发操作;当要 put 元素时并不是对整个 hashmap 加锁,而是先通过 hashcode 知道它要放在哪个分段,然后对分段进行加锁,所以多线程 put 元素时只要放在的不是同一个分段就做到了真正的并行插入,但是统计 size 时就需要获取所有的分段锁才能统计;分段锁的设计是为了细化锁的粒度。

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

这种分类是按照锁状态来归纳的,并且是针对 synchronized 的,java 1.6 为了减少获取锁和释放锁带来的性能问题而引入的一种状态,其状态会随着竞争情况逐渐升级,锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后无法降为偏向锁,这种升级无法降级的策略目的就是为了提高获得锁和释放锁的效率。

自旋锁

其实是相对于互斥锁的概念,互斥锁线程会进入 WAITING 状态和 RUNNABLE 状态的切换,涉及上下文切换、cpu 抢占等开销,自旋锁的线程一直是 RUNNABLE 状态的,一直在那循环检测锁标志位,机制不重复,但是自旋锁加锁全程消耗 cpu,起始开销虽然低于互斥锁,但随着持锁时间加锁开销是线性增长。

可中断锁

synchronized 是不可中断的,Lock 是可中断的,这里的可中断建立在阻塞等待中断,运行中是无法中断的。