学习笔记 - Java多线程——synchronized关键字


synchronized作为Java的一个关键字,是实现Java多线程编程中互斥访问的重要手段之一。简单的来说,synchronized所修饰的部分一定有一个明确的对象,所有被synchronized修饰同一对象的代码块,在同一时间至多只有一个会被某一个线程访问。

我们知道在C中实现多线程互斥访问的主要方法是互斥锁,而互斥锁又是一种特殊的信号量。那么Java中的synchronized在底层是如何实现的?被synchronized修饰的静态方法、实例方法(instance method)、静态方法中的代码块、实例方法中的代码块有什么区别?

1. synchronized语义

1.1 被synchronized修饰的实例方法

public class Test {
    public synchronized void methodA() {
        // do something
    }

    public synchronized void methodB() {
        // do something
    }
}

这种情况下的语义表明一个实例中所有被synchronized修饰的实例方法,在同一时间至多只有一个线程进入这些方法其中的一个。而不同实例则互不影响。即同一时间,该实例的方法A和方法B至多只会有一个被某一个线程调用。

在语义上,以下两种等同。

// 1
public class Test {
    public synchronized void methodA() {
        // do something
    }
}
// 2
public class Test {
    public void methodA() {
        synchronized (this) {
            // do something
        }
    }
}

1.2 被synchronized修饰的静态方法

public class Test {
    public static synchronized void staticMethodA() {
        // do something
    }
}

由于静态方法是属于整个类的,因此,在这个情况下,synchronized实际修饰的对象是整个类。并且在Java虚拟机中,一个类只会有一个实体。因此,对于这个类以及所有这个类的实例来说,同一时间至多只有一个线程能够进入被synchronized修饰的静态方法。不同的类则互不影响

在语义上,以下两种等同。

// 1
public class Test {
    public static synchronized void methodA() {
        // do something
    }
}
// 2
public class Test {
    public static void methodA() {
        synchronized (Test.class) {
            // do something
        }
    }
}

1.3 被synchronized修饰的实例方法中的代码块

public class Test {
    private int count;
    public void methodA() {
        synchronized (count) {
              // do something
        }
    }
}

synchronized关键字修饰代码块时需要显示给出其所修饰(同步)的对象,这里的对象一般为多线程中需要同步的共享变量、资源。这个对象也被称为monitor object(监视器对象)。在一个进程中,至多只有一个线程可以进入被同一个监视器对象修饰的代码块。

1.4 被synchronized修饰的静态方法中的代码块

public class Test {
    private static int count;
    public static void methodA() {
        synchronized (count) {
              // do something
        }
    }
}

这里没什么特别的,与实例方法差不多,不同点就是静态方法所能够访问的变量都必须是静态的。

2. synchronized的局限和一些替代品

  1. synchronized是严格互斥的,机不允许多于一个线程同时进入被同一个监视器对象修饰的区域。但是有些时候,如果一些线程是只读的话,是可以允许这样的多个线程同时进入的(为了提高效率)。这种情况一般会选择使用读写锁机制(Reader/writer lock),Java也提供了这样一个类ReentrantReadWriteLock
  2. 同时synchronized也不能直接用于对共享资源的数量进行同步的工作,即通常所说的生产者消费者机制。这种情况的话可以通过信号量来解决,Java也提供了信号量的类Semaphore

3. synchronized是可重入的

考虑下面的这个例子,你觉得终端会输出什么?会报错吗?

public class PlayClass {
    private int count;

    public void add() {
        synchronized (this) {
            if (++count < 5) {
                add();
            }
            System.out.println(count);
        }
    }

    public static void main(String[] args) {
        PlayClass p = new PlayClass();
        p.add();
    }
}

答案是会输出5个5!这就表明synchronized的运行机制与我们熟知的C中的互斥锁是非常不一样的,前面也提到了监视器对象(monitor object),那么这个监视器是什么呢?

4. synchronized的实现机制——Monitor

4.1 Monitor

在JVM中,每一个对象(object)和类(class)都有与之对应的监视器(原生类型没有监视器),监视器有与之对应的互斥锁,线程通过获取该互斥锁来占有监视器所保护的资源。对于对象来说,监视器所保护的资源是对象的实例变量(instance variables);对类来说,监视器所保护的资源是类变量(class variables)。

每一个监视器都有一个线程等待集合(wait set),这个集合在对象初始化或者类加载的时候是空的。这个集合的后续操作只能通过Object类的三个实例方法来实现,wait()notify()notifyAll()

其中wait方法是一个占有资源的线程主动释放资源的接口,调用该方法意味着该线程释放了监视器的锁并进入了线程等待集合。notifynotifyAll都是当前占有资源的线程给正在线程等待集合中的线程发送唤醒信号的接口,notify是给某一个等待集合中的线程发(取决于线程调度,如果有多个的话),notifyAll是给所有等待集合中的线程发。不过,这并没有任何实际意义,即并不是说被唤醒的线程就立即获得了监视器的锁,因为当前线程并没有释放锁。所以这个信号仅仅只是一个信号,被唤醒的线程还需要通过调度获得监视器的锁才算真正占有了资源。如果没有其他未进入等待集合的线程,那么被唤醒的线程会在当前线程释放锁之后获得监视器的锁。如果有其他未进入等待集合的线程,那么有可能新增的线程获得了锁,而之前被唤醒的等待线程只能再次进入等待集合。

整个监视器的逻辑结构大致如下所示

Java Monitor的逻辑结构

中间的部分即监视器所保护(同步)的资源,右侧是线程等待集合,左侧是未进入等待集合的线程(可以理解为第一次尝试进入监视器所保护区域的线程)。从图中可以看出,线程必须获得锁才能占有监视器所保护的资源。当前占有资源的线程可以通过wait进入等待集合,或是运行结束后退出。由此,我们也可以理解为什么前面提到的synchronized是可重入的,即当前占有资源的线程,只要不选择等待或者退出,那么它就一直占有资源,它不需要再次获取锁。

以下有几个值得注意的地方

  • 由于A notify BB 占有资源两者之间并没有必然联系,并且A在发送信号给B之后也可以继续占有资源并做一些操作。因此对于B来说,一个良好的习惯是每次占有资源后,需要对自己所关心的条件重新检验,如果不符合,则再次调用wait进入等待集合。
  • 如果当前线程退出前,所有等待集合中的线程均没有被唤醒(被当前线程唤醒,或者是wait设置了超时),那么只有处于entry set的线程才会竞争监视器的锁。
  • notify操作究竟会唤醒哪一个等待集合中的线程是随着底层实现而变的,可能是FIFO,可能是LIFO,也可能是其他的调度算法。
  • 对于类(class)来说,监视器实际保护的对象是该类所对应的类实例,java.lang.Class对象。
  • 监视器的锁实际上是一个计数器(相对于二元互斥锁),当前占有监视器资源的线程可以再次获得该锁(也就是我们前面的那个例子),每次获得该锁,这个计数器就会增加1,而每次释放这个锁,计数器就会减去1,直到计数器为0,表明该线程完全释放了锁,也即线程释放了监视器所保护的资源。

4.2 Nested Monitor Lockout

在出现synchronized嵌套的时候,不良的编程习惯同样会导致死锁。而monitor的机制本身可能带来另一个问题——Nested Monitor Lockout(嵌套管程锁死)。

public class Lock{
    protected MonitorObject monitorObject = new MonitorObject();
    protected boolean isLocked = false;

    public void lock() throws InterruptedException{
        synchronized(this){
            while(isLocked){
                synchronized(this.monitorObject){
                    this.monitorObject.wait();
                }
            }
            isLocked = true;
        }
    }

    public void unlock(){
        synchronized(this){
            this.isLocked = false;
            synchronized(this.monitorObject){
                  this.monitorObject.notify();
            }
        }
    }
}

这个问题出现的情形大致如下

  1. 线程A占有了监视器对象this,此时isLocked = true,线程A占有this.monitorObject之后主动进入线程等待集合,等待另一个线程唤醒自己
  2. 线程B想要唤醒A,但是线程B需要首先占有监视器对象this

这里就发生了嵌套管程锁死问题,其根本的原因就是因为线程A在等待B的唤醒,但与此同时线程A仍然占有监视器对象this,而线程B唤醒A的前提是先要占有监视器对象this,因此造成了锁死。需要注意的是这里的锁死和通常意义的死锁有略微的不同。通常意义的死锁是指双方占有不同的资源而又需要占有对方已经占有的资源而导致的锁死问题。从这一点上来说嵌套管程锁死并不是死锁,但是我个人认为从结果上来看称为死锁似乎也合理。

参考


文章作者: Shun Zhang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Shun Zhang !
 上一篇
学习笔记 - Google分布式文件系统 学习笔记 - Google分布式文件系统
本文主要介绍初代谷歌分布式文件系统的整体框架、主要部件、对应读写操作的分布式协议以及容错机制。
2020-07-01
下一篇 
学习笔记 - HTTP,cookie和session 学习笔记 - HTTP,cookie和session
本文主要整理了HTTP协议从0.9到2.0版本的演化以及背后的逻辑,并且介绍了Cookie和Session是如何将HTTP扩展成状态化的。
2020-06-29
  目录