Java synchronized的原理解析

Java基础

浏览数:92

2019-3-26

开始

 

类有一个特性叫封装,如果一个类,所有的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的锁,来锁定一个代码块。

对整个方法上锁,如:  
1 publicsynchronizedvoid aMethod(){
2     // some code...
3 }

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

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

2. 两个代码块的互斥

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

 1 class SyncData {
 2     public void do1() {
 3         synchronized(this) {
 4             for (int i=0; i < 4; i++) {
 5                 System.out.println(Thread.currentThread().getName() + "-do1-" + i);
 6                 try{
 7                     Thread.sleep(1000);
 8                 }catch(InterruptedException e) {
 9                     e.printStackTrace();
10                 }
11             }
12         }
13         
14     }
15     
16     public void do2() {
17         synchronized(this) {
18             for (int i=0; i < 4; i++) {
19                 System.out.println(Thread.currentThread().getName() + "-do2-" + i);
20                 try{
21                     Thread.sleep(1000);
22                 }catch(InterruptedException e) {
23                     e.printStackTrace();
24                 }
25             }
26         }
27     }
28 }

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

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

3. 锁的识别

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

 1 class SyncData {
 2     private Object lock = new byte[0];
 3     
 4     public void do1() {
 5         synchronized(lock) {
 6             for (int i=0; i < 4; i++) {
 7                 System.out.println(Thread.currentThread().getName() + "-do1-" + i);
 8                 try{
 9                     Thread.sleep(1000);
10                 }catch(InterruptedException e) {
11                     e.printStackTrace();
12                 }
13             }
14         }
15     }
16 }

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

 1 class SyncData {
 2     public void do1() {
 3         Object lock = new byte[0];
 4         synchronized(lock) {
 5             for (int i=0; i < 4; i++) {
 6                 System.out.println(Thread.currentThread().getName() + "-do1-" + i);
 7                 try{
 8                     Thread.sleep(1000);
 9                 }catch(InterruptedException e) {
10                     e.printStackTrace();
11                 }
12             }
13         }
14     }
15 }

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

  下面再看一段代码:

 1 class SyncData {
 2     public void do1() {
 3         synchronized(this) {
 4             for (int i=0; i < 4; i++) {
 5                 System.out.println(Thread.currentThread().getName() + "-do1-" + i);
 6                 try{
 7                     Thread.sleep(1000);
 8                 }catch(InterruptedException e) {
 9                     e.printStackTrace();
10                 }
11             }
12         }
13         
14     }
15 }

创建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&nbsp;static方法之间,也是互斥的。   

总结

 总结一下我们的结论:   
  1. 任何对象实例都有一把内部锁,只有一把。
  2. 相同的对象锁是互斥访问的充要条件。

这2个结论已经够了,重要的是识别使用的对象的锁是不是相同的。   多线程设计,考虑同步问题,我有几点想法:

  1. 一个类的实例,可能被多个线程并发访问,才考虑同步控制。
  2. 在1的前提下,只有会导致数据状态出现一段时间的不一致,相关的代码片段才需要同步控制。
  3. 在2的前提下,只有两块代码会相互干扰时,才必须使用同一把对象锁,来实现互斥;如果相互之间没有影响,建议使用不同的对象锁,以保持并发性能。

当然,在判断“数据状态是否会不一致”、“两块代码是否有干扰”的时候,是比较困难的,所以再补充2点:

  1. 在不能确认数据状态是否会不一致的情况下,按照会不一致的情况考虑
  2. 在不能确认两块代码是否有干扰的情况下,按照会有干扰的情况考虑

我们的讨论到此结束。  

参考

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