锁是Java并发编程中最重要的同步机制,使用锁可以让临界区中的代码互斥执行(即多线程串行执行)。
synchronized是Java提供的关键字,以其简单易用,成为开发者的首选。所以我们见到的大部分的并发控制都是用synchronized来实现的。
synchronized有两种形式,一种是修饰代码块,一种是修饰方法,如下
//方式一:修饰代码块
public void fun1(){
synchronized(obj){
//某些操作
}
}
//方式二:修饰方法
public synchronized void fun2(){
//某些操作
}
很容易产生的一个误区是,锁是加在临界区代码块上,比如上面的代码,很多初学者可能会认为锁是加在 “{ //某些操作 }”。其实理解synchronized的核心就是要理解锁是加在哪里,只要抓住这一点,实践中遇到的各种诡异的问题就能迎刃而解。
synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对像。如果程序中的synchronized明确指定了对象参数,例如synchronized(obj),那就是这个对象的reference;如果synchronized修饰的是实例方法或者类方法,则是取对应的对象实例或Class对象来作为锁对象。
上面这段话看起来也许难以理解,下面就让我们来一步一步学习理解synchronized。
下面的例子,我们创建了一个报数的类,启动两个线程来测试,这时不使用锁。
//示例代码1:没有锁控制的场景
public class JavaLockTest {
public static void main(String[] args) {
CountOff countOff = new CountOff();
Thread t1 = new Thread(countOff, "A");
Thread t2 = new Thread(countOff, "B");
t1.start();
t2.start();
}
}
class CountOff implements Runnable {
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println(Thread.currentThread().getName() + " say: " + i);
try {
Thread.sleep(50);//睡一小会,便于观察;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
我想大家肯定都猜到了结果,A、B两个线程的报数情况将会出现穿插,如下
//报数穿插的输出
A say: 1
B say: 1
B say: 2
A say: 2
A say: 3
B say: 3
B say: 4
A say: 4
A say: 5
B say: 5
现在,我们来改造代码,在CountOff类中加入一个实例变量作为锁对象:
//示例代码2:加入一个成员变量,用作锁对象
class CountOff implements Runnable {
private final Object lock = new Object();
public void run() {
synchronized(lock) {
for (int i = 1; i <= 5; i++) {
System.out.println(Thread.currentThread().getName() + " say: " + i);
try {
Thread.sleep(50);//睡一小会,便于观察;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
再次运行,我们得到了想要的结果,A线程报完数后,B线程接着报数,不再穿插:
//A、B依序报数,不再穿插
A say: 1
A say: 2
A say: 3
A say: 4
A say: 5
B say: 1
B say: 2
B say: 3
B say: 4
B say: 5
我们来理解一下整个过程。临界区代码块就像是一个房间,进入这个房间可以做一些美妙的事情(比如sleep)。首先我们声明了一个Object对象lock,要用它作为锁对象(锁对象拥有房间钥匙)。A线程首先执行到synchronized(lock)代码,它获得了锁(拿到钥匙),进入房间享受,B线程只能在门外等着;等A线程享受完毕,从房间退出时,会释放锁(归还钥匙),此时B线程就拿到钥匙进入房间……
理解起来很简单是不是?不要着急~~生命在于折腾,再搞一搞看?现在我们稍微改动一下main方法:
//示例代码3:修改main方法,用两个实例来运行
public static void main(String[] args) {
CountOff countOff = new CountOff();
CountOff countOff2 = new CountOff();//注意这里
Thread t1 = new Thread(countOff, "A");
Thread t2 = new Thread(countOff2, "B");//注意这里
t1.start();
t2.start();
}
看看结果:
//又变混乱了
A say: 1
B say: 1
A say: 2
B say: 2
A say: 3
B say: 3
B say: 4
A say: 4
A say: 5
B say: 5
怎么回事?这里就引出本篇文章要强调一个重点:多个线程只有在争用同一个对象上的锁时,才会形成互斥。这里请把“同一个对象”大声读三遍,谢谢~(Java如何判断两个对象是否是同一个对象?判断其地址是否相同即可)。在这个实验里,我们创建了CountOff类的两个实例来分别执行报数,锁对象countOff.lock和countOff2.lock实际上是两个不同的对象,A、B线程各持有一个,它们无法形成互斥。实践中大多数问题就出在这里。
现在我们知道把锁加在类的实例变量上是不妥当的,除非你能保证在你的系统里是以单例模式来使用该类的。—-何必费这么大劲呢?把锁对象声明为static,让它成为类变量,所有的实例都共享类变量,这样一来就符合“同一个对象”的限定,用来做锁,妥妥的~
//示例代码4:把锁对象声明为static ,这样就不用担心多实例引发的问题了
private static Object lock = new Object();
接下来,我们再看看用synchronize修饰方法。这时候大家会问:锁是加在锁对象上的,当修饰方法时,这个锁对象是谁呢? 还记得文章开头部分的那句话吗?–“如果synchronized修饰的是实例方法或者类方法,则是取对应的对象实例或Class对象来作为锁对象”。我们来改造一下示例代码,以帮助大家理解。
public class JavaLockTest2 {
public static void main(String[] args) {
final Action ac = new Action();
// A线程执行sing方法,B线程执行speak方法
Thread t1 = new Thread( new Runnable() { public void run(){ ac.sing();}}, "A");
Thread t2 = new Thread(new Runnable() { public void run(){ ac.speak();}}, "B");
t1.start();
t2.start();
}
}
class Action {
public synchronized void sing() {
for (int i = 1; i <= 5; i++) {
System.out.println(Thread.currentThread().getName() + " sing: " + i);
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void speak() {
synchronized(this) {
for (int i = 1; i <= 5; i++) {
System.out.println(Thread.currentThread().getName() + " speak: " + i);
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
//输出结果
A sing: 1
A sing: 2
A sing: 3
A sing: 4
A sing: 5
B speak: 1
B speak: 2
B speak: 3
B speak: 4
B speak: 5
在上面的代码中,声明了一个实例ac,A线程调用用synchronized修饰的sing( )方法,B线程调用speak( )方法,speak( )方法中有用synchronized(this)修饰的代码块,这里的this是指向调用该方法的类实例ac。
看输出结果,整整齐齐,说明发生了互斥,证实了synchronized 修饰实例方法时相当于 synchronized(this)。根据上一节中的内容,锁加在实例上,在多实例的场景下就失去了作用,这个大家要注意。
继续验证,把synchronized加在静态方法上的情形。我们修改一下Action类继续做测试:
class Action {
//sing方法被声明为static的
public synchronized static void sing() {
for (int i = 1; i <= 5; i++) {
System.out.println(Thread.currentThread().getName() + " sing: " + i);
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void speak() {
//这里的锁对象为Action.class,PS:类对象也是对象哦
synchronized(Action.class) {
for (int i = 1; i <= 5; i++) {
System.out.println(Thread.currentThread().getName() + " speak: " + i);
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
//输出结果
A sing: 1
A sing: 2
A sing: 3
A sing: 4
A sing: 5
B speak: 1
B speak: 2
B speak: 3
B speak: 4
B speak: 5
可以看到输出结果井然有序,证实了synchronized修饰静态方法时,是以类对象作为锁对象的。
至此,synchronized的基本用法介绍完啦。
既然Java给出了这么多种用法,当然都有其用武之地。这里只能给一些大致的建议。
synchronized修饰方法时,其粒度太大(直接以实例或类对象做为锁对象),高并发下性能容易有影响。举个极端例子,一个类10个静态方法,各个方法间不干扰,但需要保证每个方法在同一时间只能被一个线程访问,如果10个方法都用synchronized修饰,有10个线程分别要访问不同的方法,此时都会互斥,整个处理过程消耗的时间为10个方法执行时间之总和。而事实上,由于是访问不同的方法,理想情况下,整个处理过程消耗的时间应该等于时间最长的那个方法消耗的时间。
所以比较推荐的是用静态的类变量做为锁对象(实例变量在多实例的场景下会有问题,前面已经讲过),这样粒度更小,更灵活,避免引起大范围的互斥(代价是写法复杂了点)。
//推荐使用一个长度为0的数组做为锁对象,据说这样的开销比new Object()要小
private final static byte[] lock = new byte[0];
在这一节里,列举了一些使用synchronized不妥当的方式,虽然在某些特定的场景下这些方式能正常工作,但还是劝大家要小心。—- 场地那么宽,为什么要在悬崖边跳舞呢?
【1】 不要使用字符串常量作为锁对象;
private static final String lock = "LOCK";
public void sing() {
synchronized (lock) {
//TODO
}
}
在JVM中,字符串常量维护在一个池中,不同类中使用的字符串常量,如果它们的字面值相同,实际上是指向同一个对象的。
套用我们上文中一再强调的同一个对象,大家应该转过弯来了:如果系统中有另外一个类,也是使用了”LOCK”字符串常量作为锁,它们就会形成互斥。
【2】 不要使用Boolean变量作为锁对象;
这个大家可以自己验证一下。理由和【1】类似;
【3】 使用类对象做为锁对象时,最好直接使用synchronized(XXX.class) 的形式,不要用synchronized (getClass());
【4】用来作为锁对象的变量,最好只作为锁对象使用,不要对其进行操作,以防改变了对象的引用,使锁失效;
//不好的示例
private static Object lock = new Object();
public void sing() {
synchronized (lock) {
//某些操作
lock = new Object();
}
}
【5】锁具有可重入性,一个线程已经持有了一个对象的锁时,如果再次请求该对象的锁,会获得成功而不会形成死锁。
//这样并不会造成一个线程等待自己释放锁而引起死锁
private static Object lock = new Object();
public void sing() {
synchronized (lock) {
//某些操作
synchronized (lock) {
//另一些操作
}
}
}
根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
本以为写一个篇幅不长的文档很简单,没想到断断续续的写了两天,看来太高估自己了,累! 缓口气再写锁的下一篇吧 :)
错误之处还望大家多多指正。