前言:本文想要介绍Synchronized,ReentrantLock和ReentrantLock的Condition的相关用法。
Synchronized上锁
Synchronized可以修饰实例方法、静态方法和代码块。修饰代码块时,可以对具体的对象上锁,也可以对某个类(.class)上锁。
Synchronized是非公平锁
以下代码是通过给一个多线程能访问到的变量使用synchronized进行上锁,实现有序打印数字的功能。并且在最后会统计不同线程打印数字的次数:
package com.windypath.lockcondition;
public class Syn {
int count = 0;
final Object sth = new Object();
void play() {
int loopTimes = 1000;
SynThread t1 = new SynThread(loopTimes, "t1");
SynThread t2 = new SynThread(loopTimes, "t2");
SynThread t3 = new SynThread(loopTimes, "t3");
SynThread t4 = new SynThread(loopTimes, "t4");
t1.start();
t2.start();
t3.start();
t4.start();
}
public static void main(String[] args) {
Syn syn = new Syn();
syn.play();
}
class SynThread extends Thread {
int loopTimes;
public SynThread(int loopTimes, String threadName) {
super(threadName);
this.loopTimes = loopTimes;
}
@Override
public void run() {
int times = 0;
while (count <= 200000) {
synchronized (sth) {
count++;
// System.out.println(getName() + " 输出 " + count);
times++;
}
}
System.out.println(getName() + "一共输出了 " + times + " 次");
}
}
}
输出结果如下:
t2一共输出了 103061 次
t1一共输出了 37174 次
t4一共输出了 33751 次
t3一共输出了 26018 次
可以看到线程t2输出的次数比其他三个线程加起来还要多。因为synchronized是非公平锁。
synchronized的等待队列
使用synchronized上锁的对象的等待队列位于ObjectMonitor中的_waitSet。这个ObjectMonitor是底层native(也就是C/C++)的内容。
synchronized锁升级
但并不是一开始就上重量级锁,而是先优化成偏向锁,如有竞争才会升级为轻量级锁,大量的线程参与锁的竞争时,才会从轻量级锁升级到重量级锁。
上锁的对象使用其对象头中的MarkWord来存储锁的信息。
一个Java对象在内存中的存储结构包括三个部分:
- 对象头
- 实例变量
- 填充字节
其中对象头中主要存储一些运行时的数据:
- MarkWord
- Class Metadata Address (指向对象类型数据的指针)
- Array Length (是数组的话,记录长度)
锁的信息记录在对象头的MarkWord中。下图是不同的锁的MarkWord的不同位的信息:
- 偏向锁(biased lock) 偏向锁是为了避免在非多线程环境下,执行synchronized上锁时使用轻量级锁等更高等级的锁消耗资源。
偏向的意思是,被上锁的对象偏向于某个线程。其对象头会存储偏向的线程id。
- 轻量级锁(lightweight lock) 轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况。
轻量级锁和偏向锁的区别
轻量级锁的加锁过程需要多次CAS操作,而偏向锁仅需要一次CAS操作。 轻量级锁所适应的场景是线程交替执行同步块的情况。而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
synchronized锁升级观察
尝试使用一个对象,多个线程在不同的时间段为其上synchronized锁,来观察其锁状态。 thread1:马上获取,马上释放 thread2:等500ms获取,然后使用1500毫秒再释放 thread3:等待1000ms获取,然后马上释放。
此时,thread2和thread3会出现锁竞争。
源代码如下:
package com.windypath;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.openjdk.jol.info.ClassLayout;
/**
* 观察synchronized从偏向锁 -> 轻量级锁 -> 重量级锁 的过程
* 项目使用log4j2
*/
public class BiasdLock {
final static Logger log = LogManager.getLogger();
public static void main(String[] args) throws InterruptedException {
log.debug(Thread.currentThread().getName() + "最开始的状态:\n"
+ ClassLayout.parseInstance(new Object()).toPrintable());
// HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式
Thread.sleep(4000);
// 创建一个对象,用于多个不同的线程上锁用
Object obj = new Object();
log.debug(Thread.currentThread().getName() + "等待4秒后的状态(新对象):\n"
+ ClassLayout.parseInstance(obj).toPrintable());
//线程1,马上上锁马上释放
new Thread(() -> {
log.debug(
Thread.currentThread().getName() + "开始执行准备获取锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj) {
log.debug(Thread.currentThread().getName() + "获取锁执行中:\n"
+ ClassLayout.parseInstance(obj).toPrintable());
}
log.debug(Thread.currentThread().getName() + "释放锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
}, "thread1").start();
// 线程2,等线程1释放锁后再上锁
new Thread(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.debug(
Thread.currentThread().getName() + "开始执行准备获取锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj) {
log.debug(Thread.currentThread().getName() + "获取锁执行中:\n"
+ ClassLayout.parseInstance(obj).toPrintable());
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
log.debug(Thread.currentThread().getName() + "释放锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
}, "thread2").start();
// 线程3,在线程2拥有锁的时候尝试上锁
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.debug(
Thread.currentThread().getName() + "开始执行准备获取锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj) {
log.debug(Thread.currentThread().getName() + "获取锁执行中:\n"
+ ClassLayout.parseInstance(obj).toPrintable());
}
log.debug(Thread.currentThread().getName() + "释放锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
}, "thread3").start();
//主线程等待所有线程运行结束,查看状态
Thread.sleep(5000);
log.debug(Thread.currentThread().getName() + "结束状态:\n" + ClassLayout.parseInstance(obj).toPrintable());
}
}
输出如下(精简之后):
15:53:58.436 [main] main最开始的状态:non-biasable
15:54:02.854 [main] 等待4秒后的状态(新对象):biasable
15:54:02.858 [thread1] thread1开始执行准备获取锁:biasable
15:54:02.858 [thread1] thread1获取锁执行中:biased
15:54:02.859 [thread1] thread1释放锁:biased
15:54:03.367 [thread2] thread2开始执行准备获取锁:biased
15:54:03.368 [thread2] thread2获取锁执行中:thin lock
15:54:03.869 [thread3] thread3开始执行准备获取锁:thin lock
15:54:04.872 [thread3] thread3获取锁执行中:fat lock
15:54:04.872 [thread2] thread2释放锁:fat lock
15:54:04.873 [thread3] thread3释放锁:fat lock
15:54:07.868 [main] main结束状态:non-biasable
可以分析得到以下结论:
- 对于hotspot虚拟机,刚启动时创建的对象是不可偏向(non-biasable)的
- 4秒后创建的对象,状态为可偏向(biasable)
- thread1获取锁时,由于仅有一个线程为此对象上synchronized锁,因此转为偏向锁状态(biased)
- thread1释放锁时,锁对象状态依旧为偏向锁(biased),并不会回到可偏向(biasable)
- 500ms后,thread2获取锁时,锁对象的状态会升级为轻量级锁(thin lock)
- 再过500ms后,thread3也开始获取锁,未执行到synchronized代码块时,状态为轻量级锁(thin lock),执行到synchronized时,阻塞,直到thread2释放的同时马上获取锁(倒数第三第四行的日志时间一模一样都是15:54:04.872)
- thread3马上释放锁,这一刻还是重量级锁(fat lock)
- 主线程等待5秒后,锁状态恢复,但是是变为不可偏向(non-biasable)状态。
- 可以尝试把前面的等待4秒注释,这样的话一上来就会获取轻量级锁
ReentrantLock上锁
ReentrantLock是轻量级、可重入锁。在创建时可指定是否是公平锁。 ReentrantLock可以和Condition配套使用。 ReentrantLock提供了多个并发编程相关的函数可供使用,相比于synchronized而言,灵活性更高。
- ReentrantLock可支持锁是否是公平锁
- ReentrantLock提供了常规的lock()上锁的函数之外,还提供了用于轮询使用的tryLock()函数和可被打断的lockInterruptly()函数
- ReentrantLock上锁之后,可以根据业务等待不同的Condition
ReentrantLock,可以是公平锁
package com.windypath.lockcondition;
import java.util.concurrent.locks.ReentrantLock;
public class Reen {
int count = 0;
final ReentrantLock lock = new ReentrantLock(true);
void play() {
int loopTimes = 1000;
ReenThread t1 = new ReenThread(loopTimes, "t1");
ReenThread t2 = new ReenThread(loopTimes, "t2");
ReenThread t3 = new ReenThread(loopTimes, "t3");
ReenThread t4 = new ReenThread(loopTimes, "t4");
t1.start();
t2.start();
t3.start();
t4.start();
}
public static void main(String[] args) {
Reen reen = new Reen();
reen.play();
}
class ReenThread extends Thread {
int loopTimes;
public ReenThread(int loopTimes, String threadName) {
super(threadName);
this.loopTimes = loopTimes;
}
@Override
public void run() {
int times = 0;
while (count <= 200000) {
try {
lock.lock();
count++;
// System.out.println(getName() + " 输出 " + count);
times++;
} finally {
lock.unlock();
}
}
System.out.println(getName() + "一共输出了 " + times + " 次");
}
}
}
输出结果如下:
t3一共输出了 49953 次
t4一共输出了 49988 次
t1一共输出了 50077 次
t2一共输出了 49986 次
可以看到4个线程的输出基本都在50000左右。
ReentrantLock不使用Condition模拟哲学家就餐
哲学家就餐问题,即5个哲学家围在一个圆桌吃饭,但桌上只有5只筷子。哲学家思考结束后,需要同时获取左手边的筷子和右手边的筷子才能吃饭。 在这里,我们使用线程来模拟哲学家,使用ReentrantLock模拟筷子。
package com.windypath;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class DiningPhilosopher {
public static void main(String[] args) {
int numPhilosophers = 5;
Philosopher[] philosophers = new Philosopher[numPhilosophers];
Chopstick[] chopsticks = new Chopstick[numPhilosophers];
for (int i = 0; i < numPhilosophers; i++) {
chopsticks[i] = new Chopstick();
}
for (int i = 0; i < numPhilosophers; i++) {
Chopstick leftChopstick = chopsticks[i];
Chopstick rightChopstick = chopsticks[(i + 1) % numPhilosophers];
// philosophers[i] = new Philosopher(i, leftChopstick, rightChopstick);
if (i % 2 == 0) {
philosophers[i] = new Philosopher(i, leftChopstick, rightChopstick);
} else {
philosophers[i] = new Philosopher(i, rightChopstick, leftChopstick);
}
Thread thread = new Thread(philosophers[i]);
thread.start();
}
}
static class Philosopher implements Runnable {
private final int id;
private final Chopstick leftChopstick;
private final Chopstick rightChopstick;
private int eatTimes = 0;
public Philosopher(int id, Chopstick leftChopstick, Chopstick rightChopstick) {
this.id = id;
this.leftChopstick = leftChopstick;
this.rightChopstick = rightChopstick;
}
private void think() throws InterruptedException {
System.out.println("Philosopher " + id + " is thinking.");
Thread.sleep((long) ( 1000));
}
private void eat() throws InterruptedException {
leftChopstick.pickUp();
rightChopstick.pickUp();
System.out.println("Philosopher " + id + " picks up both chopsticks and eats.");
Thread.sleep((long) ( 1000));
System.out.println("Philosopher " + id + " puts down both chopsticks.");
rightChopstick.putDown();
leftChopstick.putDown();
eatTimes++;
if (eatTimes % 10 == 0) {
System.out.println("Philosopher " + id + " 目前吃了" + eatTimes + "次");
}
}
@Override
public void run() {
try {
while (true) {
think();
eat();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
static class Chopstick {
private final Lock lock = new ReentrantLock();
public void pickUp() {
lock.lock();
}
public void putDown() {
lock.unlock();
}
}
}
在上面的代码中,筷子只需要在被哲学家拿起时调用lock()函数,在放下时调用unlock()函数即可完成“同时拥有左手边的筷子和右手边的筷子”的目标。
使用Condition的代码:
package com.windypath;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class DiningPhilosophers {
public static void main(String[] args) {
int numPhilosophers = 5;
Philosopher[] philosophers = new Philosopher[numPhilosophers];
Chopstick[] chopsticks = new Chopstick[numPhilosophers];
for (int i = 0; i < numPhilosophers; i++) {
chopsticks[i] = new Chopstick();
}
for (int i = 0; i < numPhilosophers; i++) {
Chopstick leftChopstick = chopsticks[i];
Chopstick rightChopstick = chopsticks[(i + 1) % numPhilosophers];
// philosophers[i] = new Philosopher(i, leftChopstick, rightChopstick);
if (i % 2 == 0) {
philosophers[i] = new Philosopher(i, leftChopstick, rightChopstick);
} else {
philosophers[i] = new Philosopher(i, rightChopstick, leftChopstick);
}
Thread thread = new Thread(philosophers[i]);
thread.start();
}
}
static class Philosopher implements Runnable {
private final int id;
private final Chopstick leftChopstick;
private final Chopstick rightChopstick;
private int eatTimes = 0;
public Philosopher(int id, Chopstick leftChopstick, Chopstick rightChopstick) {
this.id = id;
this.leftChopstick = leftChopstick;
this.rightChopstick = rightChopstick;
}
private void think() throws InterruptedException {
System.out.println("Philosopher " + id + " is thinking.");
Thread.sleep((long) ( 1000));
}
private void eat() throws InterruptedException {
leftChopstick.pickUp();
rightChopstick.pickUp();
System.out.println("Philosopher " + id + " picks up both chopsticks and eats.");
Thread.sleep((long) ( 1000));
System.out.println("Philosopher " + id + " puts down both chopsticks.");
rightChopstick.putDown();
leftChopstick.putDown();
eatTimes++;
if (eatTimes % 10 == 0) {
System.out.println("Philosopher " + id + " 目前吃了" + eatTimes + "次");
}
}
@Override
public void run() {
try {
while (true) {
think();
eat();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
static class Chopstick {
private final Lock lock = new ReentrantLock(true);
private final Condition condition = lock.newCondition();
private boolean taken = false;
public void pickUp() throws InterruptedException {
lock.lock();
try {
while (taken) {
condition.await();
}
taken = true;
} finally {
lock.unlock();
}
}
public void putDown() {
lock.lock();
try {
taken = false;
condition.signal();
} finally {
lock.unlock();
}
}
}
}
可以看到,筷子类Chopstick加上了状态taken,用于判定目前筷子是否被某个哲学家拥有。 当第一个哲学家拥有某一只筷子的时候,taken为true;锁释放。当第二个哲学家拿起这只筷子时,还是会获得相同的锁,但会因为taken为true而进入condition.await()等待,此时也会释放锁,让其他哲学家能够获取这只筷子。 当筷子被放下时,调用signal()方法,此时之前await()函数的线程会被唤醒,执行其后序逻辑。
如果不使用公平锁,那么输出里你可能会看到有两个哲学家很晚才吃10次。如果使用公平锁,则5个哲学家几乎是同步吃到10次。
注意到在初始化哲学家时,奇数号哲学家的筷子是左右反过来拿的。这是因为在后续的获取筷子的逻辑中,我们总是先拿左手边的筷子,再拿右手边的筷子。如果不这样让一部分哲学家左右相反,那么会出现5个哲学家同时拿起左手边的筷子,然后等待右手边的筷子,造成死锁。(当然我们也可以通过随机数来让哲学家选择先左后右,还是先右后左。)