第三章 java线程同步机制 《java多线程编程实战指南-核心篇》

3.1 线程同步机制简介

线程同步机制是一套用于协调线程间的数据访问及活动的机制,该机制用于保障线程安全以及实现这些线程的共同目标。

线程同步机制包括锁、volatile关键字、final关键字、static关键字以及相关API。

3.2 锁概述

获得锁(Acquire)、释放锁(Release)

锁的持有线程在其获得锁之后和释放锁之前这段时间内所执行的代码被称为临界区。

锁有排他锁(互斥锁)和读写锁。

java中的锁实现包括内部锁(synchronized)和显式锁(java.concurrent.locks.Lock的实现类)

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

  • 锁通过互斥保障原子性;
  • 可见性的保障是通过写线程冲刷处理器缓存和读线程刷新处理器缓存这两个动作实现的。在java平台中,锁的获得隐含着刷新处理器缓存这个动作,这使得读线程在执行临界区代码前可以将写线程对共享变量所做的更新同步到该线程执行处理器的高速缓存中;而锁的释放隐含着冲刷处理器缓存这个动作,这使得写线程对共享变量所做的更新能够被推送到该线程执行处理器的高速缓存中,从而对读线程可同步。
  • 锁能够保障有序性,写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行的,即读线程对这些操作的感知顺序与源代码顺序一致。

可重入性:一个线程在其持有一个锁的时候能否再次申请该锁。

可重入锁可以被理解为一个对象,该对象包含一个计数器属性。计数器属性的初始值为0,表示相应的锁还没有被任何线程持有。每次线程获得一个可重入锁的时候,该锁的计数器值会增加1.每次一个线程释放锁的时候,该锁的计数器属性就会被减1.一个可重入锁的持有线程初次获得该锁时相应的开销相对大,这是因为该锁的持有线程必须与其他线程竞争以获得锁。可重入锁的持有线程继续获得相应锁所产生的开销小得多,这是因为此时java虚拟机只需要将相应锁的计数器属性增加1即可。

 java平台中锁的调度包括公平策略和非公平策略,锁也分为公平锁和非公平锁。内部锁属于非公平锁,显式锁既支持公平锁又支持非公平锁。

3.3 内部锁:synchronized关键字

java平台中的任何一个对象都有一个唯一一个与之关联的锁。这种锁被称为监视器或者内部锁。内部锁是一种排他锁,它能够保障原子性、可见性和有序性。

内部锁的锁句柄变量一般使用final修饰。这是因为锁句柄变量的值一旦改变,会导致执行同一个同步块的多个线程实际上使用不同的锁,从而导致竟态。同步竟态方法相当于以当前类对象为引导锁的同步块。

package JavaCoreThreadPatten.capter03;

public class LockTest {
    /**
     * 默认以当前类实例作为锁对象
     */
    public synchronized void innerLock1(){
        
    }
    private final Object lock = new Object();

    /**
     * 指定锁对象
     */
    public void innerLock2(){
        synchronized (lock){
            
        }
    }

    /**
     * 默认以当前类的class类实例作为锁对象
     */
    public synchronized static void innerLock3(){
        
    }
}

3.4 显式锁:Lock接口

ReentranLock是Lock接口的默认实现。一般锁的释放放在finally语句块中执行,防止出现异常导出锁泄漏。

ReentranLock即支持公平锁也支持非公平锁,通过构造参数中的fair参数来指定创建的是公平锁还是非公平锁。

公平锁保障锁调度的公平性往往是以增加了线程的暂停和唤醒的可能性,即增加了上下文切换为代价的。因此,公平锁适合于锁被持有的时间相对长或者线程申请锁的平均间隔时间相对长的情况。总的来说使用公平锁的开销比使用非公平锁的开销要大,因此显示锁默认使用的是非公平调度策略。

显式锁的使用更灵活,可以使用tryLock()来提升我们系统的性能,但是稍不注意会出现锁泄漏;内部锁不需要考虑锁泄漏问题,jvm会帮我们进行释放。

从《深入理解java虚拟机》这种书中其实推荐我们使用内部锁,因为内部锁在1.6之后做了一些优化,并且内部锁是隐性锁,jvm优化方便。

读写锁

读写锁是一种改进型的排它锁,也被称为共享/排他锁。读写锁允许多个线程可以同时读取共享变量,但是一次只允许一个线程对共享变量进行更新。任何线程读取共享变量的时候,其他线程无法更新这些变量;一个线程更新共享变量的时候,其他任何线程都无法访问该变量。

读锁是共享的、写锁是排他的。读锁对读线程来说起到保护其访问的共享变量在其访问期间不被修改的作用,并并使多个读线程可以同时读取这些变量从而提高并发性;而写锁保障了写线程能够以独占的方式安全的更新共享变量。ReadWriteLock接口及其实现类ReentrantReadWriteLock

package JavaCoreThreadPatten.capter03;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * 读写锁降级,先获取写锁,然后获取读锁
 */
public class ReadWriteLockDowngrade {
    private final ReadWriteLock READ_WRITE_LOCK = new ReentrantReadWriteLock();
    private final Lock READ_LOCK = READ_WRITE_LOCK.readLock();
    private final Lock WRITE_LOCK = READ_WRITE_LOCK.writeLock();

    public void operationWithLockDowngrade(){
        boolean readLockAcquired = false;
        WRITE_LOCK.lock();
        try{
            //执行写业务逻辑操作
            System.out.println("执行写操作");
            READ_LOCK.lock();
            readLockAcquired = true;
            //锁降级成功,即获取到读锁
            System.out.println("锁降级成功,即获取到读锁");
        }finally {
            WRITE_LOCK.unlock();
        }
        if(readLockAcquired){
            try{
                //执行读操作
                System.out.println("执行读操作");
            }finally {
                READ_LOCK.unlock();
            }
        }
    }

    public static void main(String[] args){
        ReadWriteLockDowngrade readWriteLockDowngrade = new ReadWriteLockDowngrade();
        readWriteLockDowngrade.operationWithLockDowngrade();
    }
}

由于读写锁内部实现比内部锁和其他显式锁要复杂的多,因此读写锁适合于下面场景使用:1.只读操作比写操作要频繁得多;2.读线程持有锁的时间比较长。只有同时满足上面两个条件的时候,读写锁才是适宜的选择。ReentrantReadWriteLock所实现的读写锁是个可重入锁。ReentrantReadWriteLock支持锁的降级,即一个线程持有读写锁的写锁的情况下可以继续获得相应的读锁。

3.5 锁的试用场景

1.check-then-act操作

2.read-modify-write操作

3.多个线程对多个共享数据进行更新

3.6 线程同步机制的底层助手:内存屏障

线程获得和释放锁时所分别执行的两个动作:刷新处理器缓存和冲刷处理器缓存。对于同一个锁所保护的共享数据而言,前一个动作保证了该锁的当前持有线程能够读取到前一个持有线程对这些数据所做的更新,后一个动作保证了该锁的持有线程对这些数据所做的更新对该锁的后序持有线程可见。

java虚拟机底层实际上是借助内存屏障来实现刷新处理器缓存和冲刷处理器缓存两个动作的。内存屏障是对一类仅针对内存读、写操作指令的跨处理器架构的比较底层的抽象。内存屏障是被插入到两个指令之间进行使用的,其作用是禁止编译器、处理器重排序从而保障有序性。它在指令序列中就像是一堵墙一样使其两侧的指令无法穿越它。但是,为了实现禁止重排序的功能,这些指令也往往具有一个副作用--刷新处理器缓存、冲刷处理器缓存,从而保证可见性。

内存屏障可以划分为以下几类:

  • 按照可见性保障来划分,内存屏障可以分为加载屏障和存储屏障。加载屏障的作用是刷新处理器缓存,存储屏障的作用冲刷处理器缓存。java虚拟机会在MonitorExit(释放锁)对应的机器码指令之后插入一个存储屏障,这就保障了写线程在释放锁之前在临界区中对共享变量所做的更新对读线程的执行处理器来说是可同步的;响应的,java虚拟机会在MonitorEnter(申请锁)对应的机器码指令之后临界区开始之前的一个地方插入一个加载屏障,这使得读线程的执行处理器能够将写线程对相应共享变量所做的更新从其他处理器同步到该处理器的告诉缓存中。因此,可见性的保障是通过写线程和读线程成对的使用存储品章和加载屏障实现的。
  • 按照有序性保障划分,内存屏障可以分为获取屏障和释放屏障。获取屏障的使用方式是在一个读操作之后插入该内存屏障,其作用是禁止该读操作与其后的任何读写操作之间进行重排序,这相当于在进行后续操作之前先要获得相应共享数据的所有权。释放屏障的使用方式是在一个写操作之前插入该内存屏障,其作用是禁止该写操作与其前面的任何读写操作之间进行重排序。这相当于在对相应共享数据操作结束后的释放所有权。java虚拟机会在MonitorEnter对应的机器码指令之后临界区之前开始的地方插入一个获取屏障,并在临界区结束之后MonitorExit对应的机器码指令之前的地方插入一个释放屏障。
  • 由于获取屏障禁止了临界区中的任何读、写操作被重排序到临界区之前的可能性,而释放屏障又禁止了临界区中的任何读、写操作被重排序到临界区之后的可能性,因此临界区内的任何读、写操作都无法被重排序到临界区之外。在锁的排他性的作用下,这使得临界区中执行的操作序列具有原子性。因此,写线程在临界区中对各个共享变量所做的更新会同时对读线程可见,即在读线程看来各个共享变量就像是一下子被更新的,于是这些线程无从区分这些共享变量是以何种顺序被更新的。这使得写线程在临界区中执行的操作自然而然的具有有序性。可见,锁对有序性的保障是通过写线程和读线程配对使用释放屏障与加载屏障实现的。

3.7 锁与重排序

重排序规则:

1.临界区内的操作不允许被重排序到临界区之外

2.临界区内的操作之间允许重排序

3.临界区外的操作之间可以重排序

4.锁申请与锁释放操作不能被重排序

5.两个锁申请操作不能被重排序

6.两个锁释放操作不能被重排序

7.临界区外的操作可以被重排序到临界区之内

3.8 轻量级同步机制:volatile关键字

volatile修饰的变量读和写操作都必须从高速缓存或者主内存中读取,以读取变量的相对新值。因此,volatile变量不会被编译器分配到寄存器进行存储,对volatile变量的读写操作都是内存访问操作。

volatile关键字常被称为轻量级锁,保证可见性和有序性。在原子性方面它仅能保障写volatile变量操作的原子性,但没有锁的排他性;其次,volatile关键字的使用不会引起上下文切换。

volatile关键字的作用包括:保障可见性、保障有序性和保障long/double型变量读写操作的原子性。volatile关键字在原子性方面仅保障对被修饰的变量的读操作、写操作本身的原子性。如果要保障对volatile变量的赋值操作的原子性,那么这个赋值操作不能涉及任何共享变量的访问。

对于volatile变量的写操作,java虚拟机会在该操作之前插入一个释放屏障,并在该操作之后插入一个存储屏障:

第三章 java线程同步机制 《java多线程编程实战指南-核心篇》_第1张图片

其中,释放屏障禁止了volatile写操作与该操作之前的任何读、写操作进行重排序,从而保证了volatile写操作之前的任何读、写操作会先于volatile写操作被提交,即其他线程看到写线程对volatile变量的更新时,写线程在更新volatile变量之前所执行的内存操作的结果对于读线程必然也是可见的。这就保障了读线程对写线程在更新volatile变量前对共享变量所执行的更新操作的感知顺序与相应的源代码顺序一致,即保障了有序性。

对于volatile变量读操作,java虚拟机会在该操作之前插入一个加载屏障,并在该操作之后插入一个获取屏障,并在该操作之后插入一个获取屏障:

第三章 java线程同步机制 《java多线程编程实战指南-核心篇》_第2张图片

其中,加载屏障通过冲刷处理器缓存,使其执行线程所在的处理器将其他处理器对共享变量所做的更新同步到该处理器的高速缓存中。读线程执行的加载屏障和写线程执行的存储屏障配合在一起使得写线程对volatile变量的写操作以及在此之前所执行的其他内存操作的结果对读线程可见,即保障了可见性。因此,volatile不仅仅保障了volatile变量本身的可见性,还保障了写线程在更新volatile变量之前执行的所有操作的结果对读线程可见。这种可见性保障类似于锁对可见性的保障,与锁不同的是volatile不具备排他性(此处我的理解其实volatile的写和读操作具备排他性,只不过仅限对与该变量的读和写操作具备排他性,即当某线程读和写该变量时是排他的,不知道我理解的对不对?),因而他不能保障读线程读取到的这些共享变量的值是最新的,即读线程读取到这些共享变量的那一刻可能已经有其他写线程更新了这些共享变量的值。另外,获取屏障禁止了volatile读操作之后的任何读、写操作与volatile读操作进行重排序。因此它保障了volatile读操作之后的任何操作开始执行之前,写线程对相关共享变量的更新已经对当前线程可见。

  • 写volatile变量操作与该操作之前的任何读、写操作不会被重排序;
  • 读volatile变量操作与该操作之后的任何读、写操作不会被重排序;

volatile变量在可见性方面仅仅是保证读线程能够读取到共享变量的相对新值。对于引用型变量和数组变量,volatile关键字不能保证读线程能够读取到相应对象字段、元素的相对新值

volatile变量的开销包括读变量和写变量两个方面。volatile变量的读、写操作都不会导致上下文切换,因此volatile的开销比锁要小。 

单例模式

package JavaCoreThreadPatten.capter03.singleton;

/**
 * 单例模式,含多线程安全问题
 * 懒汉模式
 */
public class SingleThreadedSingletonV1 {
    public static SingleThreadedSingletonV1 singleThreadedSingletonV1 = null;
    private SingleThreadedSingletonV1(){}

    /**
     * 方法体中实际上是check-then-act模式操作,存在线程安全问题
     * @return
     */
    public static SingleThreadedSingletonV1 getInstance(){
        if(singleThreadedSingletonV1 == null){
            singleThreadedSingletonV1 = new SingleThreadedSingletonV1();
        }
        return singleThreadedSingletonV1;
    }

    public void someService(){
        //一些处理方法
    }
}
package JavaCoreThreadPatten.capter03.singleton;

/**
 * 可用版本
 * 单例:饿汉模式
 * 不存在线程安全问题,但是程序启动即创建实例,可能存在用不到的情况,浪费空间
 */
public class SingleThreadSingletonV2 {
    private static SingleThreadSingletonV2 singleThreadSingletonV2 = new SingleThreadSingletonV2();

    private SingleThreadSingletonV2(){}

    public static SingleThreadSingletonV2 getInstance(){
        return singleThreadSingletonV2;
    }

    public void someService(){
        //一些处理方法
    }
}
package JavaCoreThreadPatten.capter03.singleton;

/**
 * 通过内部锁,给类加锁保证getInstance方法是串行,不会出现并发问题,但是这种方式比较影响性能,因为经过第一次创建完成实例后,获取实例仍然需要串行化,并发量高时,压力比较大
 */
public class SingleThreadSingletonV3 {
    private static SingleThreadSingletonV3 singleThreadSingletonV3 = null;
    private SingleThreadSingletonV3(){}

    public static SingleThreadSingletonV3 getInstance(){
        synchronized (SingleThreadSingletonV3.class){
            if(null==singleThreadSingletonV3){
                singleThreadSingletonV3 = new SingleThreadSingletonV3();
            }
            return singleThreadSingletonV3;
        }
    }

    public void someService(){
        //一些处理方法
    }
}
package JavaCoreThreadPatten.capter03.singleton;

/**
 * 通过双重检查锁的方式既保证了并发情况下不会创建多次,同时也考虑的并发情况下性能问题
 * 但是仍然存在问题,解释如下:尽管第一次检查对变量instance的访问没有加锁从而使竟态仍然可以存在,
 * 但是乍一看,它似乎既避免了锁的开销又保障了线程安全:一个线程T1执行到操作1的时候发现instance为        null,而此刻另外一个线程T2可能恰好刚执行完操作3而使instance值不是null;接着T1获得锁而执行临界区内的时候会再次判断instance值是否为null,此时由于该线程是在临界区内读取共享变量instance的,因此T1可以发现此刻instance值已经不为null,于是,T1不会操作3,从而避免了再次创建一个实例。当然,仅仅从可见性的角度分析结论确实如此。但是,在一些情形下为了确保线程安全光考虑可见性是不够的,我们还需要考虑重排序的因素。操作3可以分解为以下伪代码:
objRef = allocate(SingleThreadSingletonV4 .class)//子操作1:分配对象所需的存储空间
invokeConstructor(objRef);//子操作2:初始化objRef引用的对象
instance = objeRef;//子操作3:将对象引用写入共享变量
    根据锁的重排序规则,临界区内的操作可以在临界区内被重排序。因此,JIT编译器可能将上述的子操作重排序为:子操作1->子操作3->子操作2,即在初始化对象之前将对象的引用写入实例变量instance。由于锁对有序性的保障是有条件的,而操作1读取instance变量的时候并没有加锁,因此上述重排序对操作1的执行线程是有影响的:该线程可能看到一个未初始化的实例,即变量instance的值不为null,但是该变量所引用的对象中的某些实例变量的变量值可能仍然是默认值,而不是构造器中设置的初始值。也就是说,一个线程在执行操作1的时候发现instance不为null,于是该线程就直接返回这个instance变量所引用的实例,而这个实例可能是未初始化完毕的,这就可能导致程序出错。---------个人没看明白,锁不应该是保证了原子性、一致性、有序性,我认为应该即使在临界区内发生了重排序,但是对临界区外都是透明的,这里为什么在临界区内发生重排序会对操作1有影响??
 */
public class SingleThreadSingletonV4 {
    private static SingleThreadSingletonV4 instance= null;
    private SingleThreadSingletonV4 (){}

    public static SingleThreadSingletonV4 getInstance(){
        if(null==instance){//操作1:第一次检查
            synchronized (SingleThreadSingletonV4.class){
                if(null==instance){//操作2:第二次检查
                    instance= new SingleThreadSingletonV4();//操作3
                }
            }
        }
        return instance;
    }

    public void someService(){
        //一些处理方法
    }
}
package JavaCoreThreadPatten.capter03.singleton;

/**
 * 可用版本:
 * 单例模式最终版本:双重检查锁+volatile
 */
public class SingleThreadSingletonFinalV1 {
    private static volatile SingleThreadSingletonFinalV1 singleThreadSingletonFinal = null;
    private SingleThreadSingletonFinalV1(){}

    public static SingleThreadSingletonFinalV1 getInstance(){
        if(null==singleThreadSingletonFinal){
            synchronized (SingleThreadSingletonFinalV1.class){
                if(null==singleThreadSingletonFinal){
                    singleThreadSingletonFinal = new SingleThreadSingletonFinalV1();
                }
            }
        }
        return singleThreadSingletonFinal;
    }

    public void someService(){
        //一些处理方法
    }
}
package JavaCoreThreadPatten.capter03.singleton;

/**
 * 可用版本:
 * 通过内部类的方式,同样也是延迟初始化
 *
 */
public class SingleThreadSingletonFinalV2 {
    private SingleThreadSingletonFinalV2 (){}

    private static class INSTANCE_HOLDER{
        final static SingleThreadSingletonFinalV2 SINGLE_THREAD_SINGLETON_FINAL_V_2 = new SingleThreadSingletonFinalV2();
    }

    public static SingleThreadSingletonFinalV2 getInstance(){
        return INSTANCE_HOLDER.SINGLE_THREAD_SINGLETON_FINAL_V_2;
    }

    public void someService(){
        //一些处理方法
    }
}
package JavaCoreThreadPatten.capter03.singleton;

/**
 * 可用版本
 * 枚举
 */
public enum  SingleThreadSingletonFinalV3 {
    INSTANCE;

    public void someService(){
        //一些处理方法
    }
}

3.9 CAS

cas能够将read-modify-write和check-and-act之类的操作转换为原子操作。

cas是一个原子的if-then-act的操作,其背后的假设是:当一个客户(线程)执行cas操作的时候,如果变量V的当前值和客户请求(即调用)CAS时所提供的变量值A(即变量的旧值)是相等的,那么就说明其他线程并没有修改过变量V的值。执行cas时如果没有其他线程修改过变量V的值,那么下手最快的客户就会抢先将V的值更新为B,而其他客户的更新请求就会失败。【个人理解有点乐观锁的感觉】

CAS仅保障共享变量更新操作的原子性,它并不保障可见性。

原子操作工具:java.util.concurrent.atomic包下面的类:

第三章 java线程同步机制 《java多线程编程实战指南-核心篇》_第3张图片

ABA问题:共享变量经历了ABA的更新,那么这种如果不能接受,那么可以给变量增加版本号来解决。

3.10 对象的发布和逸出

线程安全问题产生的前提条件是多个线程共享变量。

对象的发布是指使对象能够被其作用域之外的线程访问:1.将对象的引用存储到public变量中;2.在非private方法中返回一个对象;3.创建内部类,使得当前对象能够被这个内部类使用。4.通过方法调用将对象传递给外部方法。

静态变量的作用:一个类被java虚拟机加载之后,该类的所有静态变量值仍然是默认值,直到有个线程初次访问了该类的任意一个静态变量才使这个类被初始化,类的所有静态变量被赋予初始值。

static关键字仅仅保障读线程能够读取到相应字段的初始值,而不是相对新值。

final关键字只能保证有序性

当一个对象的引用对其他对象可见的时候,这些线程所看到的该对象的final字段必然是初始化完毕的。final关键字的作用仅是这种有序性的保障,它并不能保障包含final字段的对象的引用自身对其他线程的可见性。

你可能感兴趣的:(并发实战)