• Java中synchronized的实现原理


    从应用程序的角度来看,线程安全问题的产生是由于多线程应用程序缺乏某种保障——线程同步机制。从广义上来说,Java平台提供的线程同步机制包括锁、volatile关键字、final关键字、static关键字以及一些相关的API,如Object.wait()/Object.nofity()等。

    一、锁的概述

      我们知道,线程安全问题产生的前提是多个线程并发的访问了共享数据(共享变量、共享资源)。因此我们很容易想到一种保障线程安全的方法——将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据依次只能被一个线程访问,该线程访问结束后其他线程才能对其进行访问。锁就是利用这种思路以保障线程安全的线程同步机制。

      锁具有排它性,即锁一次只能被一个线程持有,因此这种锁也被称为排它锁互斥锁(mutex),这种锁的实现方式代表了锁的基本原理。而读写锁,则是对排它锁的一种改进。

    按照Java虚拟机对锁的实现方式划分,Java平台中的锁包括内部锁和显式锁。内部锁通过synchronized关键字实现的,而显式锁通过ReentrantLock类实现(Lock接口对显式锁进行抽象,而ReentrantLock是Lock接口的默认实现类)。

    二、内置锁(synchronized)

    1.内置锁概述

    内置锁是通过synchronized关键字实现的,有时也把这种锁称为监视器(Monitor)。

    Java中的每一个对象都可以作为锁,具体表现为以下3种形式。

    • 对于普通同步方法,锁是当前实例对象。
    • 对于静态同步方法,锁是当前类的Class对象。
    • 对于同步方法块,锁是Synchonized括号里配置的对象。

    内置锁是一种排它锁,能保障原子性、可见性和有序性。

    线程在执行临界区的代码时必须先申请该同步块的锁,只有申请成功并获取该锁,才能够执行响应头的临界区。持有锁的线程在执行完临界区代码后会自动的释放持有的锁。在这个过程中,线程对内置锁的申请与释放的动作由Java虚拟机负责代为实施,这也正是synchronized实现的锁被称为内置锁的原因。

    内置锁的使用并不会导致锁泄露。这是因为Java编译器在将同步代码块编译为字节码时,对临界区中可能抛出的而程序代码中未捕获的异常进行了特殊(代为)处理,这使得临界区的代码即使抛出异常也不会妨碍内置锁的释放。

    2.内置锁的调度(监视器模型)

    Java虚拟机中的监视器模型可以用下图来表示,将监视器分成了三个区域,左边小方框是入口区,中间的大方框包括一个单独的线程,是监视器的持有者,右边的小方框是等待区。

    ①②

    当一个线程到达监视器区域的开始处时,它会通过最左边的1号门进入入口区。

    如果没有任何线程持有监视器,也没有其它线程在入口区中等待,这个线程就会立刻进入2号门并持有监视器。作为这个监视器的持有者,它将继续执行监视器域中的代码。如果有另一个线程正持有监视器,则该线程就必须在入口区中等待,这个线程会被阻塞,所以不会执行监视区域中的代码。

    ③④⑤

    持有监视器的线程(即活动线程)会通过两条途径释放监视器:完成它正在执行的监视区域,或者执行wait命令。

    如果活动线程执行了wait命令,则会通过3号门进入等待区,释放监视器。

    如果活动线程释放监视器前没有执行唤醒命令,则处于入口区的线程就可以通过竞争使得一个线程通过2号门进入而持有监视器。

    如果活动线程释放监视器前执行了唤醒命令,则处于入口区的线程就必须与一个(执行了notify)或多个(执行了notifyAll)等待区中的线程来竞争。如果入口区的一个线程竞争胜出,则它会通过2号门成为监视器的新持有者,如果是等待区的某个线程胜出,它会通过4号门退出等待区并重新获得监视器。

    如果活动线程执行完监视区域代码,则会通过中间区域的5号门退出,释放监视器。

    注意:

    一个线程只能通过3号门进入等待区,且只能通过4号门退出等待区。

    一个线程只有在它持有监视器时才能执行wait命令,而且它只能通过再次成为监视器的持有者才能离开等待区。

    线程在执行等待命令时可以指定一个等待时间,如果在等待时间截止前没有收到来自其它线程的唤醒命令,则会从虚拟机获得一个自动唤醒的命令。

    Java虚拟机提供了两种唤醒命令:notify和notify All 。notify会从等待区中随机选择一个线程并将其标为可能苏醒。而notify All会将等待区中的所有线程都标为可能苏醒

    Java虚拟机如何从等待区及入口区选择下一个线程来执行,很大程度上取决于JVM的设计者

    可能是等待时间最长((FIFO))的线程,也可能是等待时间最短(LIFO)的线程,也可能是随机的线程,因此我们不能依赖任何具体的选择算法。正是因为不知道notify命令会如何选择下一个线程,所以只有当绝对确定只有一个线程在等待区中等待时,才应该使用notify。而只要存在同时有多个线程在等待区中等待时,就应该使用notify All。

    3.内置锁的实现原理(JVM层面的实现)

    Java中的每一个对象都可以作为锁。具体表现为以下3种形式。

    • 对于普通同步方法,锁是当前实例对象。
    • 对于静态同步方法,锁是当前类的Class对象。
    • 对于同步方法块,锁是Synchonized括号里配置的对象。

    对于同步方法和同步代码块,两者内置锁的实现有所不同。

    (1)同步代码块的实现

    对于同步代码块,JVM则使用指令集monitorenter和monitorexit两条指令来支持synchronized语义。

    monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。线程执行到monitorenter指令时,必须要求先获得monitor才能继续执行代码块。最后当方法完成时(无论是正常完成还是发生异常)释放monitor。方法在执行期间,执行线程持有了monitor,其它任何线程都无法再获取到同一个monitor。如果一个同步方法在执行期间抛出了异常,并且方法内无法处理此异常,那么这个同步方法所持有的monitor将在异常抛出时自动释放。

    (2)同步方法的实现

    同步方法是隐式的,无须通过字节码指令来控制。

    虚拟机可以从方法常量池的方法表结构中的ACC_SYNVHRONIZED访问标识得知一个方法是否声明为同步方法。当方法调用时,调用指令会检查方法的ACC_SYNVHRONIZED访问标识是否被设置,如果设置了,执行线程就要要求先获取monitor才能执行方法,最后当方法完成时(无论是正常完成还是发生异常)释放monitor。

    4.锁的可重入性

    内置锁是可重入的,如果某个线程请求一个已经由它持有的锁,那么这个请求会成功。

    可重入的几种典型场景

    ①下面是子类方法调用父类方法的可重入。

    public class Widget {
        public synchronized void doSomething() {
            ...
        }
    }
    
    public class LoggingWidget extends Widget {
        public synchronized void doSomething() {
            System.out.println(toString() + ": calling doSomething");
            // 可重入    
            super.doSomething();
        }
    }

    ②一个类中的同步方法调用该类中另一个同步方法。

    public class Widget {
        public synchronized void doSomething() {
            System.out.println(toString() + ": calling doSomething");
            // 可重入    
            doRemaining();
        }
        
        public synchronized void doRemaining() {
            ...
        }
    }

    ③下面是递归形式的可重入。

    public class Widget {
    
        public synchronized void doSomething(int n) {
            if(n>0){
                //递归。可重入
                doRemaining();
                n--;
            } 
        }
    }

    锁的可重入性实现

    重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程所持有,当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1,如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。

    三、锁是如何保证原子性、可见性和有序性的

    锁能够保护共享数据的线程安全,其作用包括保障原子性、可见性、有序性。

    1.锁是如何保障原子性的?

    锁是通过互斥保障原子性的。所谓互斥,就是指一个锁一次只能被一个线程持有,当锁被某个线程持有时,其它线程只有等待期释放锁后再申请。因此,一个线程执行临界区代码时没有其它线程能够访问该共享数据,此时,我们可以把多线程对临界区共享数据的访问当做串行访问来看待。这就使得临界区代码自然而然的具有了原子性。

    2.锁是如何保障可见性的?

    我们知道,可见性的保障是通过写线程刷新处理器缓存和读线程刷新处理器缓存这两个动作实现。在Java平台中,锁的获得会隐含地包含刷新处理器缓存这个动作,这使得读线程在执行临界区代码前(获得锁后)可以将写线程对共享变量所做的更新同步到该线程执行处理器高速缓存中。而锁的释放也同样隐含着刷新处理器缓存这个动作,这使得写线程对共享变量所做的更新能够即时更新到该线程执行处理器的高速缓存中,从而对读线程可同步。因此,锁保障了可见性。

    3.锁是如何保障有序性的?

    由于上面可见性的保障,写线程在临界区中对共享变量所做的更新都对读线程同步可见。同时,由于临界区的操作具有原子性,因此写线程对上述共享变量的更新会同时对读线程可见。由于读线程无法(也无必要)区分写线程实际上是以什么顺序更新共享变量的,因此读线程可以认为写线程依照源代码顺序更新了上述共享变量的。即有序性得到了保障。

    尽管锁能保障有序性,但并不意味着临界区内的内存操作不能被重排序。临界区内的任意两个操作依然可以在临界区内被重排序,但不会被重排序到临界区之外。由于临界区内的操作具有原子性,写线程在临界区内对共享数据的更新同时对读线程可见,因此这种重排序并不会被其它线程产生影响。

    面试题

    1.锁的作用?

    锁能保障共享变量的线程安全性,包括原子性、可见性、有序性。

    2.锁的实现原理?

    3.锁是如何保障原子性、可见性和有序性的?

    4.什么是锁的可重入性?如何实现的?

  • 相关阅读:
    Hadoop下面WordCount运行详解
    ubuntu下hadoop环境配置
    ubuntu下的jdk安装
    ASP.NET MVC4中用 BundleCollection使用问题手记
    Lab6: Paxos
    java命令行操作
    Mesos 入门教程
    Docker background
    找实习的日子
    九度 1557:和谐答案 (LIS 变形)
  • 原文地址:https://www.cnblogs.com/rouqinglangzi/p/9036128.html
Copyright © 2020-2023  润新知