Java的锁
Java根据不同的特性来对锁进行分类,大概有以下分类方式。
这里主要讨论乐观锁和悲观锁以及在Java中对应的实现。
对于同一个数据的并发操作,悲观锁认为自己在使用数据时,一定会有其它线程来修改数据,所以在每次操作数据前都会加上一个锁,以确保没有其它线程来修改数据。Java中的synchronized锁和lock锁都是悲观锁。
而乐观锁每次都认为不会有其它线程来修改数据,所以在操作数据时不会上锁,而是在修改数据时去判断有没有其它线程修改了这个数据,如果没有被修改,则更新成功,如果已经被其它线程修改,则重新尝试或失败。Java中最常用的就是通过CAS算法来实现无锁并发编程。
根据悲观锁和乐观锁的概念可以发现:
- 悲观锁适合写多读少的场景,因为先加锁能保证写操作的正确性。
- 乐观锁适合读多写少的场景,因为读操作一般并不需要加锁(没有修改数据),所以乐观锁的无锁特性能使读性能有很大的提升(减少了加锁等待的时间)。
悲观锁
Synchroniezd锁
synchronized是一种互斥锁,也就是悲观锁,每次只允许一个线程进入synchronized修饰的方法或代码块中。synchronized锁是可重入的,即一个线程可以多次获取同一个对象或类的锁。
synchronized通过使用内置锁,对变量进行同步,来保证线程操作的原子性、有序性、可见性,可以确保多线程下的操作安全。
synchronized锁有三种使用方式,分别是对对象加锁(修饰普通方法,锁的是当前类的对象)、对代码块加锁(锁的是非当前类对象)、对类加锁(修饰静态方法,锁的是当前类)。更多:synchronized锁
下面是用synchronized锁,用N个线程循环打印0~M个数字。
public class SynchronizedTest implements Runnable {
// 定义一个对象用来保持锁
private static final Object LOCK = new Object();
// 当前线程
private int threadNum;
// 线程总数
private int threadSum;
// 当前数字,从0开始打印
private static int current = 0;
// 要打印的最大值
private int max;
public SynchronizedTest(int threadNum, int threadSum, int max) {
this.threadNum = threadNum;
this.threadSum = threadSum;
this.max = max;
}
@Override
public void run() {
// 实现N个线程循环打印数字
while (true) {
// 对代码块加锁,保证每次只有一个线程进入代码块
synchronized (LOCK) {
// 当前值 / 线程总数 = 当前线程
// 这里一定要用while,而不能用if。因为当线程被唤醒时,监视条件可能还没有满足(线程唤醒后是从wait后面开始执行)。
while (current % threadSum != threadNum) {
// 打印完了,跳出循环
if (current >= max) {
break;
}
// 不满足打印条件,则让出锁,进入等待队列
try {
LOCK.wait();
} catch (Exception e) {
e.printStackTrace();
}
}
// 这里还要做一次判断
if (current >= max) {
break;
}
System.out.println(Thread.currentThread().getName() + " 打印 " + current);
++current;
// 当前线程打印完了,通知所有等待的线程进入阻塞队列,然后一起去争抢锁
LOCK.notifyAll();
}
}
}
public static void main(String[] args) {
// 开启N个线程
int N = 3;
// 打印M个数字
int M = 15;
for (int i = 0; i < N; ++i) {
new Thread(new SynchronizedTest(i, N, M)).start();
}
}
}
Lock锁
lock锁也是一种互斥锁,同时悲观锁。它是一种显示锁,加锁与释放锁的操作都需要手动实现,而synchronized的释放锁是自动实现的。
ReentrantLock锁内部定义了公平锁和非公平锁。对于公平锁,内部维护了一个FIFO队列用来保存进入的线程,保证先进入的线程能先执行。而对于非公平锁,如果一个线程释放了锁,其它所有线程都可以去抢这个锁,这样就会导致有些人可能会饿死,可能永远也得不到执行。但是公平锁为了实现时间上的绝对顺序,需要频繁的切换上下文,而非公平锁会减少一定的上下文切换,降低了开销。所以ReentrantLock默认采用的是非公平锁,以提高性能。
reentrantLock实现可见性是通过AQS中用volatile修饰的state来实现的,下面来分析一下原理(以非公平锁为例)。
reentrantLock先显示上锁,调用lock方法。
final void lock() {
// 先尝试获取锁,也就是将state更新为1(这里用了CAS),如果获取成功,则将当前线程设置为独占模式同步的当前所有者
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
// 如果获取失败,则进入acquire()方法
else
acquire(1);
}
下面进入acquire()方法:
public final void acquire(int arg) {
// 调用tryAcquire尝试获取锁
// 如果获取锁失败,则用当前线程创建一个独占结点加到等待队列的尾部,并继续尝试获取锁
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这里只进入tryAcquire看看:
protected final boolean tryAcquire(int acquires) {
// 内部又调用了一个非公平的尝试获取锁方法
return nonfairTryAcquire(acquires);
}
进入往下看:
final boolean nonfairTryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 重点!首先从主存中获取state(state是个volatile修饰的变量)
int c = getState();
// 如果state为0,说明没有获取过锁
if (c == 0) {
// 尝试获取锁
if (compareAndSetState(0, acquires)) {
// 将当前线程设置为独占模式当前所有者
setExclusiveOwnerThread(current);
return true;
}
}
// 如果state不为0,说明之前获取过锁
else if (current == getExclusiveOwnerThread()) {
// 将锁的数量叠加
int nextc = c + acquires;
if (nextc < 0) // 溢出(超过最大锁的数量)则抛出异常
throw new Error("Maximum lock count exceeded");
// 因为当前线程已经获取了锁,在这一步不会有其它线程来干扰,所以不需要用CAS来设置state
setState(nextc);
return true;
}
return false;
}
上面就是获取锁的主要代码,如果获取失败了,将会被加入到等到队列中继续尝试获取锁。(这一步不再分析)
下面再来看看释放锁的过程:
public void unlock() {
// 通过内部类调用父类AbstractQueuedSynchronizer的release方法
sync.release(1);
}
下面进入release方法:
public final boolean release(int arg) {
// 调用tryRelease方法来尝试释放锁
if (tryRelease(arg)) {
Node h = head;
// 如果头节点不为空且等待状态非0
if (h != null && h.waitStatus != 0)
// 如果头节点的后继节点存在,则唤醒它
unparkSuccessor(h);
return true;
}
return false;
}
reentrantLock的内部类sync重写了tryRelease方法:
protected final boolean tryRelease(int releases) {
// 重点!也是首先获取state,并减去要释放的锁的数量
int c = getState() - releases;
// 如果当前线程不等于当前独占模式拥有者线程
if (Thread.currentThread() != getExclusiveOwnerThread())
// 抛出一个非法监视器状态异常
throw new IllegalMonitorStateException();
boolean free = false;
// 如果持有锁的数量为0
if (c == 0) {
// 设置锁为可释放
free = true;
// 把当前独占线程清空
setExclusiveOwnerThread(null);
}
// 设置state
setState(c);
return free;
}
以上就是释放锁的关键代码
通过以上分析可知,每次在加锁和释放锁的时候,都会进入方法时先获取state,最后以设置state结束。
由于state变量是通过volatile修饰的,所以state对于所有线程是可见的,又因为volatile变量在每次强制刷新到主内存的时候,会将非volatile变量也刷新回主存。
在加锁的代码中,肯定是先调用lock(由于操作了volatile的state(先读后写),会强制刷新主存),最后调用unlock(也要操作state,会再次强制刷新主存),根据happens-before规则,volatile变量的写对于下一次的读是可见的。所以这保证了同步代码中的共享变量是可见的。
下面是一个利用reentrantLock实现的循环交替打印ABC,其中还使用了locks的条件变量condition。
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockTest {
// 定义一个显示锁
private static ReentrantLock lock = new ReentrantLock();
// 监控a的条件变量
private static Condition a = lock.newCondition();
// 监控b的条件变量
private static Condition b = lock.newCondition();
// 监控c的条件变量
private static Condition c = lock.newCondition();
// 控制要打印的值
private static int flag = 0;
public static void printA() {
for (int i = 0; i < 5; i++) {
// 显示加锁
lock.lock();
try {
try {
while (flag != 0) {
// 不满足监视条件则等待
a.await();
}
System.out.println(Thread.currentThread().getName() + "我是A");
flag = 1;
// 通知b线程去打印
b.signal();
} catch (Exception e) {
e.printStackTrace();
}
} finally {
// 释放锁
lock.unlock();
}
}
}
public static void printB() {
for (int i = 0; i < 5; i++) {
lock.lock();
try {
try {
while (flag != 1) {
b.await();
}
System.out.println(Thread.currentThread().getName() + "我是B");
flag = 2;
c.signal();
} catch (Exception e) {
e.printStackTrace();
}
} finally {
lock.unlock();
}
}
}
public static void printC() {
for (int i = 0; i < 5; i++) {
lock.lock();
try {
try {
while (flag != 2) {
c.await();
}
System.out.println(Thread.currentThread().getName() + "我是C");
flag = 0;
a.signal();
} catch (Exception e) {
e.printStackTrace();
}
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) {
new Thread(() -> {
printA();
}).start();
new Thread(() -> {
printB();
}).start();
new Thread(() -> {
printC();
}).start();
}
}
synchronized锁和reentrantLock锁
相同点
- 都能对资源加锁,保证线程间的同步访问。
- 都是可重入锁,即一个线程能多资源反复加锁。
- 都保证了多线程操作的原子性、有序性、可见性(这个只能保证共享变量在加锁操作内的可见性,而在加锁操作外的可见性不能得到绝对的保证,因为锁外不能保证一直从主存中获取数据,工作内存可能会不同步)
不同点
- 同步实现机制不同
- synchronized通过java对象关联的monitor监视器实现
- reentrantLock通过AQS、CAS等实现
- 可见性实现机制不同
- synchronized通过java内存模型来保证可见性
- reentrantLock通过AQS的state(volatile修饰的)来保证可见性
- 监控条件不同
- synchronized通过java对象作为监控条件
- reentrantLock通过Condition(提供 await、signal 等方法)作为监控条件
- 使用方式不同
- synchronized可用来修饰实例方法(锁住实例对象)、静态方法(锁住类对象)、同步代码块(锁住指定的对象)
- reentrantLock需要显示的调用lock加锁,并需要在finally中释放锁
- 功能丰富程序不同
- synchronized只是简单的加锁
- ReentrantLock 提供定时获取锁、可中断获取锁、Condition(提供 await、signal 等方法)等特性。
- 锁类型不同
- synchronized只支持费公平锁。
- reentrantLock支持公平锁和非公平锁,但是非公平锁效率更高
在 synchronized 优化以前,它比较重量级,其性能比 ReentrantLock 要差很多,但是自从 synchronized 引入了偏向锁、轻量级锁(自旋锁)、锁消除、锁粗化等技术后,两者的性能就相差不多了。
乐观锁
CAS算法
即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。
CAS算法涉及到三个操作数:
- 需要读写的内存值V(在内存中的值)
- 进行比较的值A(输入的值)
- 要写入的新值B(要更新的值)
当且仅当值V等于值A时,CAS通过原子方式将值V更新为B(比较并替换是一个原子操作,unsafe底层通过操作系统来保证原子性),如果V不等于A,则失败或重试。这里没有涉及到锁操作,所以是很高效的。
但是它存在三个问题:
- 循环开销大。CAS如果长时间操作不成功(写的并发量比较大),会导致长时间自旋,从而造成CPU资源的浪费。
- 只能保证一个变量的原子操作。但是开始JDK1.5提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
- ABA问题。如果CAS先把值改为B,又改回A。在CAS看来这个值是没有变化的,但实际上是变化了的。最典型的就是ATM取钱问题:余额100,我取出50,此时ATM开了两个线程,但是一个线程暂时挂了,一个线程成功把余额更新为50,然后我朋友又给我转了50,此时余额为100,但是刚刚那个取钱的线程又活了,继续刚刚的操作,尝试将100更新为50,emmm,在CAS看来它是可以成功的,但这是不符合逻辑的(我朋友转给我的50块去哪啦???)。ABA问题的一般解决思路就是在变量前加个版本号,这样更新操作就变成了1A->2B->3A,这样CAS就会认为他们不一样了。JDK1.5开始提供AtomicStampedReference中引入了标志,这个类的compareAndSet()方法中需要当前标志和预期标志相同才能更新成功(每次更新时都会更新这个标志)。
这里结合AtomicStampedReference和CountDownLatch实现一个ABA的例子(通过版本号可以解决问题)
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicStampedReference;
public class CasTest {
// 定义一个原子类型变量
private static AtomicStampedReference<Integer> asr = new AtomicStampedReference<>(1, 0);
// 定义一个线程计数器
private static CountDownLatch countDownLatch = new CountDownLatch(1);
public static void main(String[] args) {
new Thread(() -> {
// 打印当前线程的值
System.out.println("线程 " + Thread.currentThread().getName()
+ " value" + asr.getReference());
// 最开始的版本
int stamp = asr.getStamp();
try {
// 等待其它线程全部执行完毕(这里只需等待线程2运行结束)
countDownLatch.await();
} catch (Exception e) {
e.printStackTrace();
}
// 将1改为2,又改为1后,再次尝试将最开始的版本的1修改为2
// 操作结果应该是失败的,因为当前版本(0)与预期版本(2)不同
// 如果将取版本号的操作放在当前,操作结果肯定是成功的(因为这里修改的1不是最开始版本的1)
System.out.println(Thread.currentThread().getName()
+ " CAS操作结果 "
+ asr.compareAndSet(1, 2, stamp, stamp + 1));
}, "线程1").start();
new Thread(() -> {
// 把值修改为2
asr.compareAndSet(1, 2, asr.getStamp(), asr.getStamp() + 1);
System.out.println("线程 " + Thread.currentThread().getName()
+ " value" + asr.getReference());
// 把值修改为1
asr.compareAndSet(2, 1, asr.getStamp(), asr.getStamp() + 1);
System.out.println("线程 " + Thread.currentThread().getName()
+ " value" + asr.getReference());
// 当前任务执行完毕,等待线程数减1
countDownLatch.countDown();
}, "线程2").start();
}
}