文章目录
前言
Java里的多线程是通过Thread类实现的,实现线程同步的方法有很多种,比如synchronized,CAS + volatile,Object类自带的wait和notify等。那么synchronized的原理又是什么,JDK1.5后新出的Lock框架又能实现哪些功能,线程同步的核心是锁,那么锁有哪些分类,不同的锁有哪些特点。无锁的CAS如何实现线程同步?这些是这一篇的内容。
Lock框架
Lock接口:JDK1.5之后,在java.util.concurrent.locks包下提供的另一种方式实现同步访问,那就是Lock。相比之下,Object的方法较陈旧,功能也没那么强大。
Question:为什么有了synchronized还需要Lock?
Ans:如果获取锁的线程要等待IO,或者其他原因阻塞了,但又没有释放锁,此时其他线程只能一直等待。除此之外,有些情况下读和写会有冲突问题,写和写有冲突问题,但读和读之间不会冲突。如果直接使用synchronized来实现同步,将导致读和读也产生冲突。因此synchronized关键字存在缺陷,在一些情况下效率比较低下。而Lock提供了更多的功能,可以根据实际情况的需要,使用Lock来替换synchronized。
/**
* {@code Lock} implementations provide more extensive locking
* operations than can be obtained using {@code synchronized} methods
* and statements. They allow more flexible structuring, may have
* quite different properties, and may support multiple associated
* {@link Condition} objects.
*
* <p>A lock is a tool for controlling access to a shared resource by
* multiple threads. Commonly, a lock provides exclusive access to a
* shared resource: only one thread at a time can acquire the lock and
* all access to the shared resource requires that the lock be
* acquired first. However, some locks may allow concurrent access to
* a shared resource, such as the read lock of a {@link ReadWriteLock}.
*/
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
java.util.concurrent.locks主要的类/接口框架:
Lock:根接口,定义了加锁,解锁等方法。
ReentrantLock:Lock的实现类
区分读写锁则用到了ReadWriteLock接口和ReentrantReadWriteLock实现类,内部包含了WriteLock和ReadLock
Condition提供了类似Object的monitor方法。
Lock方法
包括lock,unlock,tryLock(返回boolean)等方法。一般任务存放在try-catch中,在finally中unlock,而tryLock可以在if里进行判断:tryLock有另一个带参数的版本,也是在指定时间内获得锁就返回true,否则false。lockInterruptibly方法,也是获取锁,但是等待的过程中可以响应中断。
例子:
public static void main(String[] args) {
Lock lock = new ReentrantLock();
if (lock.tryLock()) {
try {
// TODO
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
为了确保锁一定会unlock,避免死锁的发生,unlock方法最好放置在finally里执行。
Question:下面的代码为什么不能保证同步?
public class Test {
public static List<Integer> list = new ArrayList<>();
public static void insert(Thread thread) {
Lock lock = new ReentrantLock();
lock.lock();
System.out.println(thread.getName() + " caught the lock");
try {
for (int i = 0; i < 5; i++)
list.add(i);
Thread.sleep(new Random().nextInt(1000));
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
System.out.println(thread.getName() + " release the lock");
}
}
public static void main(String[] args) {
Thread t1 = new Thread("Thread1") {
@Override
public void run() {
insert(Thread.currentThread());
}
};
Thread t2 = new Thread("Thread2") {
@Override
public void run() {
insert(Thread.currentThread());
}
};
t1.start();
t2.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
Ans: Lock作为局部变量,不可保证同步,每个线程调用insert的时候都会生成一个局部变量Lock,无法阻塞其他线程。正确的做法:把Lock设置为类的成员变量:private static Lock lock = new ReentrantLock();
读写锁
主要是使用ReentrantReadWriteLock类。
public class Test {
// 接上一个Question的代码,ReentrantLock为成员变量,略
// ...
private static ReadWriteLock rwlock = new ReentrantReadWriteLock();
public static void get(Thread thread) {
rwlock.readLock().lock();
try {
for (int i = 0; i < 10; i++)
System.out.println(thread.getName() + " is reading:" + list.get(i));
} finally {
rwlock.readLock().unlock();
}
}
public static void main(String[] args) {
// ...
Thread r1 = new Thread("readThread1") {
@Override
public void run() {
get(Thread.currentThread());
}
};
Thread r2 = new Thread("readThread2") {
@Override
public void run() {
get(Thread.currentThread());
}
};
r1.start();
r2.start();
}
}
执行结果:
readThread2 is reading:0
readThread2 is reading:1
readThread2 is reading:2
readThread2 is reading:3
readThread2 is reading:4
readThread2 is reading:0
readThread1 is reading:0
readThread2 is reading:1
readThread1 is reading:1
readThread1 is reading:2
readThread2 is reading:2
readThread1 is reading:3
readThread2 is reading:3
readThread1 is reading:4
readThread1 is reading:0
readThread2 is reading:4
readThread1 is reading:1
readThread1 is reading:2
readThread1 is reading:3
readThread1 is reading:4
两个读线程之间不会产生阻塞,读锁不会影响其他线程获取读锁。如果使用synchronized或者ReentrantLock,那么两个只读线程也会相互阻塞。
Lock与synchronized的区别与选择
①Lock是一个接口,synchronized是Java的关键字,是内置的语言实现
②synchronized在发生异常时,会释放线程占有的锁,因而不会导致死锁。而Lock在发生异常时,如果没有主动地调用unlock去释放锁,那么很有可能造成死锁。因此使用Lock时需要在finally中释放锁
③Lock可以让等待锁的线程响应中断,而synchronized不行,会一直等待下去。
④通过Lock方法可以知道有没有成功获取锁,而synchronized不行。
⑤Lock的读写锁机制可以提高多个线程进行读操作的效率。(读操作多于写操作的情况)
锁的分类
Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。
①可重入锁和不可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象是同一个对象或者.class对象),不会因为之前已经获取过锁还没释放而阻塞。ReentrantLock和synchronized都是可重入锁,Reentrant就是可重入的意思,表明了锁的分配机制:基于线程的分配,而不是基于方法,即一个线程对本方法递归,无须重复获得锁。
例子:
public class Widget {
public synchronized void doSomething() {
System.out.println("方法1执行...");
doOthers();
}
public synchronized void doOthers() {
System.out.println("方法2执行...");
}
}
上述例子中,类的两个方法都是被内置锁synchronized所修饰,所以使用的是可重入锁,doSomething方法里调用了doOthers方法。因为是可重入锁,所以该线程在调用doSomething的时候获得了当前对象的锁,然后在内部调用doOthers方法的时候,因为已经获得了当前对象的锁,所以可以直接进入doOthers方法进行操作。
如果这是一个不可重入锁,那么当前线程在进入doOthers方法之前,需要将doSomething方法时获取到的当前对象的锁释放,然后再在doOthers处获取。但此时是在doSomething内部调用的doOthers,所以该对象锁已经被当前线程所持有,且无法释放,所以此时会出现死锁。
为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?我们通过图示和源码来分别解析一下。
有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁。
但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。
之前我们说过ReentrantLock和synchronized都是重入锁,那么我们通过重入锁ReentrantLock以及非可重入锁NonReentrantLock的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。
首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
可重入锁(ReentrantLock源码实现):
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {// 如果当前线程就已经是占有锁的线程
int nextc = c + acquires; // status值加1
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true; // 返回true,表示可以access
}
return false;
}
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException(); // 释放锁也要判断是否是已占有锁的线程
boolean free = false;
if (c == 0) { // 只有status等于0,才是真正的释放锁
free = true;
setExclusiveOwnerThread(null);
}
setState(c); // 修改status的值
return free;
}
非可重入锁的实现(ThreadPoolExecutor的内部类Worker,也是继承自AQS,但实现逻辑是非可重入锁):
protected boolean tryAcquire(int unused) {
if (compareAndSetState(0, 1)) { // 直接获取锁
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false; // 只要对象锁已经被获取,那么就返回false
}
protected boolean tryRelease(int unused) {
setExclusiveOwnerThread(null);
setState(0); // 释放锁时也是直接将status置为0
return true;
}
②公平锁和非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
公平锁(ReentrantLock的FairSync子类的tryAcquire方法):
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() && // diff
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
非公平锁(NonfairSync子类):
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
公平锁与非公平锁的区别在于,公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors();
:
public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
该方法主要做的一件事,判断当前线程是否位于同步队列中的第一个,如果是,返回true,否则false。所以公平锁就是通过同步队列来实现多个线程按照线程申请锁的顺序来获取锁。
③乐观锁和悲观锁
定义
悲观锁:每次去拿数据都认为别人会修改,所以每一次都要加锁。Java中所有切实的锁,基本都是悲观锁。
乐观锁:每次去拿数据都认为别人不会修改,所以不上锁。但是在更新时,还是要判断一下数据有没有被修改。如果修改了,说明中途有其他线程对次资源进行了修改,此时当前线程需要重新读取,再次尝试更新,循环执行直到更新成功。(当然,也可以放弃)。实际上乐观锁根本就不是锁,它一般通过“不使用锁”的方式来实现,最常用的就是CAS算法。
二者比较:悲观锁会阻塞事务,乐观锁会回滚重试。显然,当任务读操作较多,冲突较少时,乐观锁的效率更高,省去了锁的开销(比如Redis等缓存)。而当写操作较多,冲突较多的时候,此时上层应用反复重试,反而降低了性能,在这种情况下悲观锁更加适合。
原理
CAS(Compare-And-Swap),先比较,再考虑是否发生了变化,没有就说明没有出现冲突,执行交换。如果发生了改变,就重试。
CAS的原理:Java里很多地方的CAS代码,最后都是调用sun.misc.Unsafe类里的方法:
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
这最底层是C++实现的native方法,因为要从硬件层面保证了操作的原子性。
CAS存在的问题
①ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
②循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
CAS典例-AtomicInteger
java.util.concurrent包里,提供了几个基本类型的原子类,如AtomicInteger就是具备原子性的Integer。它的原理就是通过CAS实现了乐观锁,CAS自旋volatile变量。volatile保证了可见性和有序性,CAS保证了原子性,因而无锁实现了线程安全。先看原子类的定义:
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
/**
* Creates a new AtomicInteger with the given initial value.
*
* @param initialValue the initial value
*/
public AtomicInteger(int initialValue) {
value = initialValue;
}
}
该类的值是存储在一个名为value的变量里,它是一个volatile变量,无参构造器默认为0。unsafe负责获取并操作内存的数据(底层调用native方法)。valueOffset存储value在AtomicInteger中的偏移量。
常用方法:
incrementAndGet相当于++i;
,getAndIncrement相当于i++;
getAndSet:设置新值,返回原值。还有set方法,但不保证原子性。
以getAndIncrement为例:
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
那么,Unsafe类里的getAndAddInt方法:
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
Object是传入的volatile变量,代码则是CAS自旋。此处的getIntVolatile和compareAndSwapInt都是native方法,在JNI里是借助于一个CPU指令完成的,属于原子性操作,可以保证多个线程都能够看到同一个变量的修改值。
④ 偏向锁,轻量级锁,重量级锁
synchronized原理:管理对象的monitor,javap -c 或 javap -verbose可得到字节码:
synchronized有三种锁:
①偏向锁:偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。如果自始至终使用该锁的线程只有一个,偏向锁几乎无额外开销,性能极高。
②轻量级锁:是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。即,一旦有第二个线程加入锁竞争,偏向锁将升级为轻量级锁(又称自旋锁),而没有抢到锁的线程将自旋,不停地循环判断锁是否能够被成功获取。长时间的自旋操作非常消耗资源,这称为忙等(busy waiting)。如果只有轻微的锁竞争,此时synchronized就使用轻量级锁。使用短时间的忙等,换区线程在用户态和内核态之间切换的消耗。
③重量级锁:忙等是有限度的(默认是自旋10次),当锁竞争情况比较严重,此时就会升级为重量级锁。当其他线程发现当前线程被占用的是重量级锁,则线程会直接挂起,等待将来被唤醒,而不是一直处于busy waiting的状态。
Monitor是线程私有的数据结构,每一个线程都有一个可用的monitor record列表,里面还有一个标记位,记录锁的情况:
00:轻量级锁 10:重量级锁 11:GC标记
01:如果记录了线程的ID,那么是偏向锁。如果记录的是hashCode,那么无锁。
总结:轻量级锁用于优化重量级锁,偏向锁用于优化轻量级锁。
JDK从1.6开始加入的偏向锁和轻量级锁。但在竞争比较激烈的情况下,反而会降低效率(多了一个锁升级的过程),此时可通过JVM参数禁用偏向锁: -XX: -UseBiasedLocking
⑤ 自旋锁
自旋锁是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。
简单的实现:
import java.util.concurrent.atomic.AtomicReference;
public class SpinLock {
private AtomicReference<Thread> owner = new AtomicReference<Thread>();
public void lock() {
Thread currentThread = Thread.currentThread();
// 如果锁未被占用,则设置当前线程为锁的拥有者
while (!owner.compareAndSet(null, currentThread)) {
}
}
public void unlock() {
Thread currentThread = Thread.currentThread();
// 只有锁的拥有者才能释放锁
owner.compareAndSet(currentThread, null);
}
}
SimpleSpinLock里有一个owner属性持有锁当前拥有者的线程的引用,如果该引用为null,则表示锁未被占用,不为null则被占用。
这里用AtomicReference是为了使用它的原子性的compareAndSet方法(CAS操作),解决了多线程并发操作导致数据不一致的问题,确保其他线程可以看到锁的真实状态。
缺点:
①CAS操作需要硬件的配合
②保证各个CPU的缓存(L1,L2,L3,跨CPU Socket,主存)的数据一致性,通讯开销很大,在多处理器系统上更严重
③没办法保证公平性,不保证等待进程 / 线程按照FIFO的顺序获得锁