深入理解Java虚拟机【十三】线程安全与锁优化【13.2】线程安全

13.2 线程安全(P466 ~ P478)

    当多个线程同时访问同一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其它的协同操作,调用这个对象的行为都能获得正确的结果,那就称这个对象是线程安全的。

    这是书中P467中原话,我相信这段话已经将线程安全的定义很形象的表达了出来,因此在此我也不再对之进行任何画蛇添足的自我描述。但是虽然话意表达的十分清楚,但是要做到却非常的困难,在任由开发者随意(单次/组合)调用对象行为的基础上保证线程安全基本是不可能实现的(原因见下文),因此我们往往会弱化语义,即保证单次调用对象行为的情况下是线程安全的。即便如此,线程安全问题即使是对于多年深耕于此的老手,也依然是令人头疼的问题。

13.2.1 Java语言中的线程安全(P467 ~ P471)

    在Java语言中对于线程安全的强弱共有以下5个等级,依次由强到弱排列。

不可变

    不可变见名知意,即对象被正确的创建(即没有发生this引用逃逸)出来后永远都不会发生改变,而既然不会发生改变,那就一定是线程安全的。在Java中,final关键字是实现不可变的具体方式,被其修饰的对象可获有不可变的特性,但需要强烈注意的是,final关键字只可保证基本类型、部分基本类型的封装类、字符串集枚举类型的不可变性,而对于引用类型,final关键字仅仅只能保证对象本身不可变,却无法保证对象的内部字段不可变(除非内部字段也修饰了final)。除此之外还有容器类(List、Set及Map等),final关键字也无法阻拦其内部对象的增减改。因此对于引用类型,还需开发者自行控制以保证其不可变性。

绝对线程安全

    绝对线程安全即是线程安全定义的直接体现,即无论开发者如何(单次/组合)调用对象行为,都能获得正确的结果。对于绝对线程安全,基本是不可能实现的,因为这要求对象的所有调用行为(包括组合调用行为)都属于原子操作,这并不现实,因为对象行为的组合调用方式理论上是无限的。

线程A线程B
T1判断客户α是否存在(线程安全)
T2判断客户α是否存在(线程安全)
T3客户α不存在,新增客户α(线程安全)
T4客户α不存在,新增客户α(线程安全)

    上方表格是个很简单的案例。【判断客户是否存在】与【新增客户】都属于线程安全的单次调用行为,但两者的组合调用行为【判断客户是否存在,不存在则新增】却并不安全。按表格中的时间片分配,最终会产生两个客户α,明显是错误结果,这仅仅是最简单的组合调用行为,而组合调用方式理论上是无限的。

相对线程安全

    相对线程安全便是我们一般定义上的线程安全,是绝对线程安全的弱化语义。相对线程安全要求对象的单次调用行为是线程安全的,但不要求组合调用行为线程安全。相对线程安全的对象在单次调用对象行为时是自由的,但在组合调用对象行为时需要使用任何额外的同步手段来保证程序的正确性。当然,你也可以将组合调用行为编码成一个线程安全的单次调用行为(即一个方法)来避免额外的同步手段。

线程兼容

    线程兼容即对象本身是线程不安全的,但是可以通过额外的同步手段来保证对象在程序运行时的正确性。对于线程兼容的对象,无论是单次调用行为还是组合调用行为,都必须在同步手段下进行,否则就是线程不安全的。

线程对立

    线程对立表示无论是否使用额外的同步手段,都无法保证对象的线程安全性。Java语言天生支持多线程环境,因此线程对立这种这种情况是极其少见的,但也并非没有,书中举例了典型的案例,此处便不再赘述。

13.2.2 线程安全的实现方法(P471 ~ P478)
互斥同步(悲观锁)

    互斥同步是实现同步最常见且最主要的一种方法。

    互斥同步的原理是保证共享数据在某时刻下只能被一条线程使用。互斥同步本身有临界区、互斥量、信号量等多种实现方式,但映射到具体到语言特性中就只有两类同步手段,synchronized关键字Lock接口实现类

    synchronized关键字是Java实现互斥同步最基本的手段,是一种块状结构的同步语法。Javac在对synchronized关键字进行编译后会在同步块的前后分别生成monitorenter(监视器进入)和monitorexit(监视器退出)两个字节码指令,这两个指令都需要一个引用类型的参数作为锁对象。如果我们直接在方法上修饰了synchronized关键字,则Java会自动判断方法是否为静态方法并自动将方法所属类的Class对象或当前对象作为锁对象。而如果在方法内部使用了synchronized关键字,则将块中的参数作为锁对象。

    /**
     * @Description: 如果在实例方法上修饰synchronized关键字,Java自动将方法所属类的当前对象作为锁对象。
     */
    public synchronized void test(){
        System.out.println("Hello World!!!");
    }
    
    /**
     * @Description: 如果在静态方法上修饰synchronized关键字,Java自动将方法所属类的Class对象作为锁对象。
     */
    public synchronized static void test(){
        System.out.println("Hello World!!!");
    }
    
    /**
     * @Description: 如果在方法内部使用了synchronized关键字,则将块中的参数作为锁对象。
     */
    public void test(){
        synchronized (this){
            System.out.println("Hello World!!!");   
        }
    }

    synchronized关键字锁是可重入锁,并且无法被强制释放。当线程执行到synchronized关键字生成的同步块时,会判断锁对象是否未被持有或者已被自身持有,如果是则执行monitorenter指令进入同步块,并将锁计数器的值加1,否则便进入阻塞状态。这意味着一个线程可以多次进入同一个同步块(常见于方法迭代)中而不会因为锁对象已被本身持有而导致死锁,这便是所谓可重入锁的概念。而当线程退出同步块时会执行monitorexit指令并将锁计数器的值减1,只有当锁计数器的值归零时才表示当前线程释放了锁,而允许被其它阻塞状态的线程持有。由于synchronized关键字锁的释放完全依据锁计数器的值,因此synchronized关键字锁无法被强制释放,并且也无法强制其它阻塞状态的线程中断,这一点与数据库不同。

    synchronized关键字锁是重量级锁。我们已经知道Java线程的实现方式是内核线程实现(即1:1实现),Java线程会直接映射到系统内核线程上,Java线程状态的变化也由操作系统完成,是非常消耗资源时间的,因此当线程因为synchronized关键字锁而阻塞或唤醒的操作就显得非常重量级,因此synchronized关键字锁也别称为重量级锁。关于重量级锁有许多的优化方案,关于这点我们放到后面再说。

    Lock接口实现类是Java实现互斥同步的另一套非块状方案,摆脱了语言特性的束缚,从类库层面来实现同步,而ReentrantLock(可重入锁)是其最通用的一种实现,也是互斥同步的首选。与synchronized关键字相比,ReentrantLock类无法被修饰在方法上,其使用方法基本与在方法内部使用synchronized关键字相同。两者的性能在早期是ReentrantLock类明显占优,但自JDK6起Java中便添加了针对synchronized关键字的大量优化措施,因此如今性能已经不再是选择两者的权衡条件,ReentrantLock类相比synchronized关键字更多的是特性层面上的优势。

    public void test(){
        Lock lock = new ReentrantLock();
        lock.lock();
        try {
            System.out.println("Hello World!!!");
        } finally {
            lock.unlock();
        }
    }
  • 等待可中断:ReentrantLock类可设置等待锁的时间,也允许等待的线程被其它线程中断,该特性在等待长任务时非常有用;
  • 公平锁:公平锁是指多个线程在竞争同一个锁时严格按申请锁的时间顺序依次获取锁。synchronized关键字是非公平锁,ReentrantLock类默认情况下也是非公平锁,但可以通过构造函数声明声明公平的ReentrantLock类对象,只是在性能上会答复下降;
  • 绑定多个条件:ReentrantLock类对象可以同时绑定多个Condition对象。

    ReentrantLock类在功能上是synchronized关键字的超集(父集),但并代表其能替代synchronized关键字。虽然ReentrantLock类拥有更多特性,但synchronized关键字也有其自身的优势。

  • 简洁:synchronized关键字是语法层次的同步,使用更加简洁清晰;
  • 自动释放锁:ReentrantLock类,或者说Lock接口的所有实现类都必须在finally块中手动的释放锁,否则将大概率造成死锁问题。Lock接口的所有实现类的实现了并非全是可重入的,这也大大增加了死锁的可能;
  • 优化前景广阔:相比于Lock接口的实现类,JVM对synchronized关键字更容易进行优化,因为JVM可以在线程或锁对象的元数据中保存synchronized关键字锁的相关信息,但是Lock接口的实现类很难做到这一点。

    所谓悲观锁是指开发者悲观的认为程序在运行过程中一定会遇到线程安全问题,从而使用互斥同步这种耗时耗力的重量级的操作来进行避免,因此互斥同步也被称为悲观锁。synchronized关键字与Lock接口的实现类在外在表现上都属于悲观锁的范畴,但有意思的是ReentrantLock类的内部运行却大量采用了乐观锁机制。

非阻塞同步(乐观锁)

    互斥同步采用阻塞的方式来避免出现线程安全问题,因此互斥同步也被称为阻塞同步。阻塞同步悲观的认为只要不加锁,便必然出现问题,即使在某个时段中程序仅有一条线程也会对之加锁,这便导致了大量资源的无谓消耗,因此我们往往有另外一个选择,乐观的认为程序不会或仅会少量的遇到线程安全问题。而对于少量几次的线程安全问题可以通过使之失败再进行一定次数的重试的方式使得程序正确运行,从而避免使用互斥(阻塞)这类重量级的同步措施。这种同步方式被称为非阻塞同步,也可称之为乐观锁。

    实现非阻塞同步的关键在于如何判断程序遇到了线程安全问题,实现该步的关键是一个比较的过程。我们假设有一个共享标志变量,当我们对其它共享数据进行计算前先查询这个共享标志变量的值,这里假设其为A,当完成其它共享数据的计算并准备将之保存前,将A与当前共享标志变量的最新值进行比较,如果相等,则表示没有遇到线程安全问题,结果保存成功,并设置标志位的最新值,否则便代表错误,程序进行重试。这个步骤被称为compare - and - swap(比较 - 交换),简称CAS操作。

    使用CAS操作要注意ABA问题。所谓ABA问题是指共享标志变量的值从A变到B,又从B变回A,造成未有其它线程参与竞争的假象的情况。解决该问题的方式也十分简单,只要不允许共享标志变量的值重复出现即可。

    CAS操作的实现得益于硬件的发展。CAS操作有个很关键的点在于其必须是一个原子操作,在比较 - 交换的过程中不允许有其它线程交替执行,否则该操作本身就是线程不安全的。实现这一点本身并不难,通过互斥同步很容易就能实现,但问题在于一个为了避免互斥同步而诞生的操作本身就是用了互斥同步的话,其存在便没有了任何意义,因此我们只能寄希望于硬件,尝试用一条处理器指令来完成这本该多次操作来实现的任务,幸运的是如今计算机已经实现这一点。

    非阻塞同步的优点在于其避免了无谓资源的浪费,缺点是编写相对复杂,且只适用于并发量较低的程序中,否则不断的重试操作本身也是一种浪费资源的行为。

无同步方案

    要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者并没有必要的联系。如果可以保证数据的线程私有化,同样可以在不进行任何同步操作的情况下保证程序的线程安全。

    可重入代码代表可以在代码的任何时刻中断它,转而去执行另外一段代码(包括递归调用自身),再回到原代码的时,不会出现任何错误,结果也不会有任何影响。要注意的是,这里的可重入与可重入锁的可重入不是一个概念。还需要提及的点是,这里的中断并不是线程中断,转而启动另一个线程,而是指一个线程停止当前正在执行的代码转而去执行另一段代码。这种情况在Java中是不存在的,因为Java的多线程是真正的多线程,但在许多单线程的前端语言中是存在的,因为它们会使用栈纠缠的方式来模拟多线程,而栈纠缠的本质恰恰与之相同。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

说淑人

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值