开始


类有一个特性叫封装,如果一个类,所有的field都是private的,而且没有任何的method,那么这个类就像是四面围墙+天罗地网,没有门。看起来就是一个封闭的箱子,外面的进不来,里面的出不去,一般来说,这样的类是没用的。

现在为这个类定义一个public的method,这个method能够修改这个类的field,相当于为这个箱子开了一个门。门有了,然后访问者就有了,当一个时间段,有多个访问者进来,就可能会发生并发问题。
 
并发问题是个什么问题?最经典的例子就是转账,一个访问者从账户A扣取一部分金额,加到账户B上。在A账户扣取之后,B账户转入之前,数据处于不一致的状态,另一个访问者如果在这个时候访问B账户,获取的数据就是有问题的。这就是并发问题,导致这个问题的出现基于2个条件:1.访问者的操作导致数据在一段时间内是不一致的;2.可以有多个访问者同时操作。如果能够破坏其中一个条件,就可以解决并发问题了。我们的关注点是在第2个条件上。
 
回到那个箱子,回到那个门。我们设想为这个门加一把锁,一个访问者进了这个门,就上锁,期间其他访问者不能再进来;等进去的访问者出来,锁打开,允许另一个访问者进去。

1. 给一个代码块上锁

synchronized可以上锁、解锁。但是它本身并不是锁,它使用的锁来自于一个对象:任何对象实例都有一把内部锁,只有一把。synchronized不仅仅可以对整个method上锁,还可以对method内的某个代码块上锁。
比如下面这种用法:

synchronized(obj){
// some code...
}

这个用法就是使用了obj的锁,来锁定一个代码块。

对整个方法上锁,如:

 publicsynchronizedvoid aMethod(){
// some code...
}

这个时候它使用的是当前实例this的锁,相当于下面的模式:

publicvoid aMethod(){
synchronized(this){
// some code...
}
}

2. 两个代码块的互斥

一个代码块,被上了锁,就无法同时接纳多个线程的访问。如果是2个不同的代码块,都被上了锁,它们之间是否会有影响呢?请看下面的代码:

 class SyncData {
public void do1() {
synchronized(this) {
for (int i=0; i < 4; i++) {
System.out.println(Thread.currentThread().getName() + "-do1-" + i);
try{
Thread.sleep(1000);
}catch(InterruptedException e) {
e.printStackTrace();
}
}
} } public void do2() {
synchronized(this) {
for (int i=0; i < 4; i++) {
System.out.println(Thread.currentThread().getName() + "-do2-" + i);
try{
Thread.sleep(1000);
}catch(InterruptedException e) {
e.printStackTrace();
}
}
}
}
}

创建1个SyncData的实例,开启2个线程,一个线程调用实例的do1方法,另一个线程调用实例的do2方法,你会看到他们之间是互斥的——即使2个线程访问的是实例的不同的方法,依然不能同时访问。因为决定是否可以同时访问的不再是门,而是锁。只要使用的是相同的对象锁,就会互斥访问

上文中关于门的比喻已经不合适了,因为在代码中你可以发现两个门(do1、do2)使用了同一把锁(this),而这和我们的常识经验是相违背的,下文也不会再出现“门”。

3. 锁的识别

可以使用任何对象的锁,比如你可以专门创建一个对象,只提供锁的功能:

 class SyncData {
private Object lock = new byte[0]; public void do1() {
synchronized(lock) {
for (int i=0; i < 4; i++) {
System.out.println(Thread.currentThread().getName() + "-do1-" + i);
try{
Thread.sleep(1000);
}catch(InterruptedException e) {
e.printStackTrace();
}
}
}
}
}

思考下面的代码是否能起到互斥访问的作用:

 class SyncData {
public void do1() {
Object lock = new byte[0];
synchronized(lock) {
for (int i=0; i < 4; i++) {
System.out.println(Thread.currentThread().getName() + "-do1-" + i);
try{
Thread.sleep(1000);
}catch(InterruptedException e) {
e.printStackTrace();
}
}
}
}
}

这个是不能起到互斥作用的,因为每一次调用,局部变量lock都是不同的实例。也就是说,synchronized使用的锁总是变化的。所以我们再补充一点:只有使用相同的对象锁,才能互斥访问。所以识别所使用的锁,是很重要的。

 
下面再看一段代码:

 class SyncData {
public void do1() {
synchronized(this) {
for (int i=0; i < 4; i++) {
System.out.println(Thread.currentThread().getName() + "-do1-" + i);
try{
Thread.sleep(1000);
}catch(InterruptedException e) {
e.printStackTrace();
}
}
} }
}

创建2个实例,分别交给2个线程中的1个去访问,能互斥吗?

不可以,因为每一个实例使用的都是自身的锁,相互之间是不同的锁,所以不能互斥。如果把代码改成这样呢:

class SyncData {
public void do1() {
synchronized(this.getClass()) {
for (int i=0; i < 4; i++) {
System.out.println(Thread.currentThread().getName() + "-do1-" + i);
try{
Thread.sleep(1000);
}catch(InterruptedException e) {
e.printStackTrace();
}
}
} }
}

可以互斥,不管一个类有多少个实例,它们调用getClass()返回的结果都是同一个实例。

讨论这个问题,是因为可以在static的method上使用synchronized,而其本质,就是使用了上面那种实例的锁,所以不同的synchronized static方法之间,也是互斥的。

总结


总结一下我们的结论:
  1. 任何对象实例都有一把内部锁,只有一把。
  2. 相同的对象锁是互斥访问的充要条件。
这2个结论已经够了,重要的是识别使用的对象的锁是不是相同的。
 
多线程设计,考虑同步问题,我有几点想法:
  1. 一个类的实例,可能被多个线程并发访问,才考虑同步控制。
  2. 在1的前提下,只有会导致数据状态出现一段时间的不一致,相关的代码片段才需要同步控制。
  3. 在2的前提下,只有两块代码会相互干扰时,才必须使用同一把对象锁,来实现互斥;如果相互之间没有影响,建议使用不同的对象锁,以保持并发性能。
当然,在判断“数据状态是否会不一致”、“两块代码是否有干扰”的时候,是比较困难的,所以再补充2点:
  1. 在不能确认数据状态是否会不一致的情况下,按照会不一致的情况考虑
  2. 在不能确认两块代码是否有干扰的情况下,按照会有干扰的情况考虑
我们的讨论到此结束。
 

参考


  1. Java中Synchronized的用法
    介绍了使用synchronized的几种方式,以及相互的区别,写的很好,建议也看一下,相互印证。
05-07 15:49