首先明确一下只有多个线程需要协同时,才可能需要用到锁。如果多个线程完全独立并行,加锁反而是画蛇添足的操作。如果我们需要控制一段代码同时只能被一个线程执行,那么最简单和便利的方式就是使用 Java 语言提供的 synchronized 关键字。sychronized 关键字非常好用,但如果不了解其背后的原理非常容易写出难以发现的 Bug。
先看一段简单的使用 synchronized 进行线程同步的代码示例:
package demo;
public class Counter {
private int counter=0;
synchronized public void addOne() {
this.counter ++;
}
synchronized public int get() {
return this.counter;
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < 100; j++) {
counter.addOne();
if (j % 10 == 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
threads[i] = t;
}
for (Thread t:threads) {
t.start();
}
for (Thread t:threads) {
t.join();
}
System.out.println("Counter value: " + counter.get());
}
}
复制代码
这段代码的 addOne 方法,如果不加锁,在多个线程执行时,执行的结果无法保证得到 1000(你会大概率看到一个接近 1000 的数字),因为多个线程会相互覆盖相加后的结果。注意的是,Counter 的 get()方法也需要加上 synchronized 关键字。虽然在上面这个简单例子中加不加都一样,但如果 get()方法不加,你可以想像一下你在计算中途试图调用 get()方法获取 counter 值,会得到什么结果?
synchronized 工作原理
synchronized 是一个关键字,它可以加在方法或者代码块上。Java 编译器在编译被 synchronized 装饰的代码时,会自动在代码前后插入加锁和释放锁的代码,以确保程序不会出现忘记释放锁的情况(和垃圾回收一样那么贴心!),但这种编译时加锁和释放锁的机制在带来便利性的同时,也牺牲了灵活性,你无法手动的控制锁,也无法实现可重入锁、读写锁等更加高级的锁特性。
临界区和受保护的资源
被 synchronized 修饰的方法或者代码块被称为:临界区。什么样的代码需要被加锁?一定是因为这段代码要操作某些对象,而这个对象被多个线程所共享。例如:给下面这段代码加锁没有任何意义,因为这段代码没有操作任何共享的对象,加锁反而多此一举。
synchronized public void dummy() {
System.out.println("hello wolrd!");
}
复制代码
在临界区中被程序操作的对象,称为受保护资源。加锁是因为要操作共享的资源,这是理解锁和用好锁的要点。那问题来了:锁在保护什么共享资源?如何保护共享资源?
sychronzied 锁住了什么?
如果 synchronized 加在一个对象方法上,那么锁住的是方法所在的对象实例:this;
如果 synchronized 加在一个类方法上,那么锁住的是类的 Class 对象;
如果 synchronized 加在一个代码块上,那么锁住的是传给 sychronized 的对象;
当使用 synchronized 锁住代码块时,大部分人习惯使用的方式是传入 this 对象,这非常方便,也和把 synchronized 加在对象方法上保持一致。但很多时候,如果一个对象中的多段代码都需要加锁去锁定不同的资源,使用 this 往往不可行,那我们可以用一个自定义的 Object 对象作为锁定的对象。
public void addOne() {
synchronized(this) {
this.counter ++;
}
}
复制代码
或者
final private Object myLock = new Object();
public void addOne() {
synchronized(myLock) {
this.counter ++;
}
}
复制代码
注意作为锁的对象最好声明为 final,而且对象的内容必须保持不变,一般习惯上用 Object 作为锁对象。如果锁对象的内容在运行时发生了变化,那么锁将会失去效用。
还有一个点要注意的是,锁作用的对象是临界区,但一个临界区可以操作多个共享资源,因此一把锁可以锁住多个共享资源。但这里有一个很容易产生错误的点,就是某个共享资源在多个共享区中被操作,如果它没有被同一把锁给锁住,就会导致错误。
如下面代码所示,counter 对象在两个临界区中被操作,但每个临界区用的是不同的锁,导致 counter 的值没有被正确加减,如果要得到正确的结果,addOne()和 minusOne()都应该使用 sameLock 进行加锁:
package demo;
public class Counter {
private int counter=0;
final private Object sameLock = new Object();
final private Object addOneLock = new Object();
final private Object minusOneLock = new Object();
public void addOne() {
synchronized(addOneLock) {
this.counter ++;
}
}
public void minusOne() {
synchronized (minusOneLock) {
this.counter --;
}
}
synchronized public int get() {
return this.counter;
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread[] addOneThreads = new Thread[10];
Thread[] minusOneThreads = new Thread[5];
for (int i = 0; i < 10; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
counter.addOne();
if (j % 10 == 0) {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
addOneThreads[i] = t;
}
//创建5个减法线程
for (int i = 0;i<5;i++) {
Thread t = new Thread(() -> {
for (int n = 0; n<10000;n++) {
counter.minusOne();
if (n % 10 == 0) {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
minusOneThreads[i] = t;
}
for (Thread t:addOneThreads) {
t.start();
}
for (Thread t: minusOneThreads) {
t.start();
}
for (Thread t:addOneThreads) {
t.join();
}
for (Thread t: minusOneThreads) {
t.join();
}
System.out.println("Counter value: " + counter.get());
}
}
复制代码
最佳实践总结:
当有多个共享资源需要加锁控制时,给每个共享资源只加一把锁,让锁和资源 1:1 对应,这样无论你在哪个临界区去操作这个变量,都能实现正确的控制。
线程等待和通知机制
让我们先回忆一下排队买火车票的场景。一个售票窗口(临界区)同时只能服务一个人,其他人只能在后面排队等待。如果一个人来到了窗口,但发现自己要买的车次暂时没票了,那么这个人需要退到一边,让下一个人买票。等到这个车次有票了,售票员大喊一声:某某车次有票了。于是所有要购买这个车次的人都冲向售票窗口,但他们也需要重新排队,而且有可能某个人排到了窗口,发现又没票了,于是只好继续等待。
Java 线程的等待和通知机制基本上和上述生活中的场景一致。当我们使用 sychronized 给一段代码加锁时,如果一个线程先获得了锁且还没有释放,那么其它线程试图执行这段加锁的代码,会进入一个阻塞队列排队等待锁。Java 会给每一把锁创建一个阻塞线程队列,等到当前执行线程释放锁后,Java 从这个队列中按先来后到的顺序选择下一个线程执行。
同时获得锁的线程在执行过程中发现条件不满足,可以主动进入等待状态,同时释放锁让其它线程执行,等到条件满足后再被通知执行。在 Java 中实现这样的功能要使用到一对方法:
某个线程主动进入等待:lock.wait()
某个线程通知其它线程:lock.notifyAll()、lock.notify()
所有调用 lock.wait()方法的线程会进入到这个 lock 的另外一个阻塞队列中(注意:和等待 synchronized 锁的阻塞队列不是同一个)。当 lock.notifyAll()方法被调用,所有 wait 状态的线程会被唤醒,然后尝试获得锁并执行。注意当一个线程获得锁,有可能条件又不满足了,因此一般我们要把 wait()调用放到 while 循环中:
while(!condition) {
lock.wait();
}
复制代码
如果没有特殊的理由,不要使用 notify()。notify()方法只会随机选择一个线程来执行,万一这个线程条件不满足继续等待,而其它线程又没有被唤醒,会导致某些线程永远不被执行的情况。
下面是一个简单的示例,这里我特意创建了一个 lock,然后调用 lock.wait(), 而不是大家常用的 this.wait()。
package demo;
public class WaitNotifyDemo {
private int box=0;
private Object lock = new Object();
public void putMoney(int value) {
synchronized (lock) {
box = value;
lock.notifyAll();
}
}
public int getMoney() throws InterruptedException {
int result;
synchronized (lock) {
while (box == 0) {
lock.wait();
}
result = box;
box = 0;
return result;
}
}
public static void main(String[] args) {
WaitNotifyDemo demo = new WaitNotifyDemo();
Thread someOne = new Thread(() -> {
for (int i = 0; i < 10; i++) {
demo.putMoney(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
demo.putMoney(-1);
});
someOne.start();
Thread picker = new Thread(() -> {
while (true) {
try {
int money = demo.getMoney();
if (money < 0) {
System.out.println("no more money!");
break;
}
System.out.println("pick money: " + money);
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
picker.start();
}
}
复制代码
注意:在上述代码中,我们调用了 Thread.sleep()来模拟时间间隔,而 sleep()方法并不会释放锁,即使这个线程处于 sleep 状态,其它线程也依然在等待。因此在实际生产场景中,我们一般要避免在锁代码块中使用 sleep。
评论