Bootstrap

六、04【Java 多线程】之并发编程

多线程并发编程

并行和并发的概念我们之前有提到过。在回顾下

并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。

并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的同时进行。

那么在多线程编程实战中,线程的个数往往大于CPU的个数,所以一般都称为多线程并发编程而不是多线程并行编程。

在多核CPU时代的到来打破了单核CPU对多线程效能的限制。多个CPU意味着每个线程都可以使用自己的CPU运行,减少了线程上下文切换的开销,随着对应用系统性能和吞吐量的要求,出现了处理海量数据和请求的需求,这些对高并发多线程编程有这急需的要求。

线程安全的问题

并发编程三要素,也是线程的安全性问题所体现。

原子性:一个不可再被分割的颗粒。原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。

可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。

有序性:程序执行的顺序按照代码的先后顺序执行(处理器可能会对指令进行重排序);

出现线程安全问题的原因

1)线程切换带来的原子性问题。

2)缓存导致的可见性问题。

3)编译优化带来的有序性问题。

解决办法

  • JDK Atomic开头的原子类、synchronized、Lock解决原子性问题。
  • synchronized、volatile、Lock解决可见性问题。
  • Happens-Before 规则可以解决有序性问题。

多线程编程中,有可能会出现多个线程同时访问同一个共享&可变资源的情况,这个资源称之为临界资源。这种资源可能是:对象、变量、文件等。

共享:资源可以由多个线程同时访问。

可变:资源可以在其生命周期内被修改。

由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问!

所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。也就是在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。

Java 提供了两种方式来实现同步互斥访问:synchronized 和 Lock;

同步器的本质就是加锁;目的就是:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问);

注:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全问题。

那是不是说只要有多个线程去访问同一个资源就一定出现线程安全的问题?答案是否定的。

如果多个线程都是读取这个资源,那就不会出现线程安全的问题。但有一个线程去修改资源了,才会出现线程安全的问题。

synchronized

synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制 synchronized 代码块不会被多个线程同时执行。synchronized 可以修饰类、方法、变量。

在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。在 Java 6 之后官方从 JVM 层面对 synchronized 进行了较大优化,所以现在的 synchronized 锁效率也优化得很不错了。

JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

synchronized 的使用方式

修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。

修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

总结:synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能!

synchronized 的具体使用

1)双重校验锁实现对象单例(线程安全)

public class Singleton {
    private volatile static Singleton uniqueInstance;

    private Singleton() {}

    public static Singleton getUniqueInstance() {
        // 先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            // 类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。

uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

synchronized 实现原理

synchronized是Java中的一个关键字,在使用的过程中并没有看到显示的加锁和解锁过程。因此有必要通过javap命令,查看相应的字节码文件。

synchronized 同步语句块的情况

public class SynchronizedDemo{
    public void method(){
        synchronized(this){
            System.out.println("synchronized 代码块");
        }
    }
}

通过JDK 反汇编指令 javap -c -v SynchronizedDemo

可以看出在执行同步代码块之前之后都有一个monitor字样,其中前面的是monitorenter,后面的是离开monitorexit,不难想象一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令。

也就是 synchronized 基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。JVM 内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。

synchronized关键字被编译成字节码后会被翻译成 monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置,来保证代码的安全性。 

为什么会有两个 monitorexit 呢?

这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。仅有ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit。

synchronized 锁升级的原理

synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。

锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。

synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。

一般加锁的方式:

1)同步实例方法,锁是当前实例对象。

2)同步类方法,锁是当前类对象。

3)同步代码块,锁是括号里面的对象。

注意:synchronized 关键字会引起线程上下文切换带来线程调度开销问题。

使用synchronized可以实现线程安全性问题,也就是可见性和原子性,但synchronized是独占锁,没有获取内部锁的线程会被阻塞掉。那这样只能同一个时间只有一个线程调用被修饰的方法变量了,这显然降低了并发性。那有没有更好的实现解决方案呢?有的,就是内部使用CAS算法实现的原子性的 AtomicLong 操作类。

synchronized 代码语义

主要是解决共享变量内存可见性问题的。在进入到 synchronized 块的时候是把在 synchronized 块内实用到变量从线程的工作内存中清除,然后从主内存中获取,以保证取到的数据是最新的。在退出的时候也是把 synchronized 块内对共享变量的修改更新到主内存当中。这也就是加锁和解锁的语义。

volatile

使用锁的方式可以解决共享变量内存的可见性问题,但是使用锁太繁重,会带来线程上下文切换开销。对于解决这种问题Java提供了一种弱形式的同步,也就是volatile关键字。volatile是Java虚拟机提供的轻量级的同步机制。它有如下两个作用:

1)保证被volatile修饰的共享变量对所有线程可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。

2)禁止指令重排序优化。

volatile可见性

就是被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中。

public class VolatileSample {

    private volatile boolean initFlag = false;

    public void save(){
        this.initFlag = true;
        String threadName = Thread.currentThread().getName();
        System.out.println(threadName + " 线程,修改共享变量initFlag");
    }

    public void load(){
        String threadName = Thread.currentThread().getName();
        while (!initFlag){
            // 线程空转
        }
        System.out.println(threadName + " 线程,感知到共享变量initFlag被修改了");
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileSample volatileSample = new VolatileSample();

        Thread t1 = new Thread(() ->{
            volatileSample.save();
        }, "t1");

        Thread t2 = new Thread(() ->{
            volatileSample.load();
        }, "t2");

        t2.start();

        Thread.sleep(1000);

        t1.start();

        System.out.println("main() 执行结束");
    }

}

但是 volatile 无法保证原子性;

private static volatile int i = 0;
public static void increase(){
    i++;
}

在并发场景下,i 变量的任何改变都会立即反应到其他线程中,但是如此存在多条线程同时调用increase() 方法的话,就会出现线程安全问题,因为 i++ 操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成(读/写),如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于 increase() 方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,也就是可见性,因此在这样种情况下就完全可以省去volatile修饰变量。

volatile 防止指令重排

指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。编译器、处理器也遵循这样一个目标。

需要注意是在单线程情况下。如果是多线程的情况下指令重排序就会给程序带来(可见性)问题。

volatile就是防止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。

那一般在什么时候使用 volatile 关键字呢?

1)写入变量值不依赖变量的当前值的时候。如果依赖当前值,那将会是 读取>计算>写入 3步操作,这几个操作并不是原子性的,而volatile不保证原子性。

2)读写变量值时没有加锁。因为加锁自身已经保证了内存可见性,这时候就不需要把变量声明为volatile。

volatile 语义

主要是保证共享变量的可见性。当一个变量被 volatile 修饰,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是把值更新到主内存。当其他线程读取该变量时,会从主内存从新获取最新的值,而不是使用当前线程工作内存中的值。volatile的语义和synchronized的语义是差不多的。

原子性

原子性操作是指在执行一系列操作时,这些操作要么都执行,要么都不执行。不存在只更新一部分的情况。

比如一个计数器,一般都是读取当前值,进行+1,然后再更新进去。这一过程就是:读>改>写;如果不能保证这个过程是原子性的,那么就会出现线程安全问题。

比如下面这个代码就是线程不安全的。

public class CounterInc{
    private int counter;

    public int getCounter(){
        return counter;
    }
    
    public void inc(){
        ++counter;
    }
}

想想我们有多少种方式能让它线程安全???

Lock

与使用 synchronized 语句相比,Lock 实现提供了更广泛的锁操作。它们允许更灵活的结构,可能具有完全不同的属性,并可能支持多个相关联的Condition对象。

锁是一种控制多个线程对共享资源访问的工具。通常锁提供对共享资源的独占访问:一次只有一个线程可以获得锁,而对共享资源的所有访问都要求首先获得锁。然而,一些锁可能允许并发访问共享资源,例如ReadWriteLock锁。

同步方法或语句的使用提供了对与每个对象关联的隐式监控器锁的访问,但强制所有锁的获取和释放都以块结构的方式发生:当获得多个锁时,它们必须以相反的顺序释放,并且所有锁必须在获得锁的同一个词法作用域中释放。

虽然同步方法和语句的作用域机制使监视锁的编程更加容易,并有助于避免许多涉及锁的常见编程错误,但在某些情况下,需要以更灵活的方式处理锁。例如,一些并行访问数据结构的算法需要使用“hand-over-hand”或“chain - locking”:先获取节点A的锁,然后是节点B,然后释放A获取C,然后释放B获取D,以此类推。Lock接口的实现允许在不同的作用域中获取和释放锁,并允许以任何顺序获取和释放多个锁,从而支持使用此类技术。

随着灵活性的增加,还需要承担更多的责任。没有块结构的锁,就不会自动释放同步方法和语句中的锁。在大多数情况下,应该使用下面的语法:

Lock l = ...;
l.lock();
try {
   // access the resource protected by this lock
} finally {
   l.unlock();
}

当锁定和解锁发生在不同的作用域时,必须注意确保持有锁时执行的所有代码都受到try-finally或try-catch的保护,以确保在必要时释放锁。

锁的实现提供额外的功能在使用同步方法和语句通过提供一个非阻塞的尝试获得一个锁(tryLock()),企图获得锁,可以打断(lockInterruptibly()和试图获得锁超时(tryLock(long,TimeUnit))。Lock类还可以提供与隐式监视器锁完全不同的行为和语义,例如保证排序、不可重入使用或死锁检测。如果一个实现提供了这样的专门化语义,那么该实现必须记录这些语义。

注意,Lock实例只是普通的对象,它们本身可以用作同步语句中的目标。获取lock实例的监视器锁与调用该实例的任何lock()方法没有指定的关系。为了避免混淆,建议永远不要以这种方式使用Lock实例,除非在它们自己的实现中。

除非特别注明,否则为任何参数传递空值都会导致抛出NullPointerException异常。

所有的锁实现必须执行与内置监视器锁提供的相同的内存同步语义,如Java语言规范(17.4内存模型)中所述:

1)成功的锁操作与成功的锁操作具有相同的内存同步效果。

2)成功的解锁操作与成功的解锁操作具有相同的内存同步效果。

不成功的锁定和解锁操作,以及重入锁定/解锁操作,不需要任何内存同步效果。

锁获取的三种形式(可中断的、不可中断的和定时的)在性能特征、顺序保证或其他实现质量方面可能有所不同。而且,在给定的lock类中,中断正在进行的锁获取的能力可能是不可用的。因此,实现不需要为所有三种形式的锁获取定义完全相同的保证或语义,也不需要支持正在进行的锁获取的中断。需要一个实现来清晰地记录每个锁定方法提供的语义和保证。它还必须遵守在这个接口中定义的中断语义,在一定程度上支持锁获取的中断:完全支持,或者只支持方法入口。

由于中断通常意味着取消,而且对中断的检查经常是不频繁的,因此实现可以优先响应中断而不是正常的方法返回。即使可以显示中断发生在另一个操作解除了线程阻塞之后,这也是正确的。实现应该记录这种行为。

由于Lock是一个接口,所以只提供了几个方法,具体方法实现,还得实现类来做

已知的实现类:ReentrantLock, ReentrantReadWriteLock.ReadLock, ReentrantReadWriteLock.WriteLock

// 获取锁
public void lock();
// 获取锁,除非当前线程被中断。
public void lockInterruptibly();
// 返回一个绑定到这个Lock实例的新的Condition实例。
public Condition newCondition();
// 仅当锁在调用时是空闲的时,才获取锁.
public boolean tryLock();
// 如果锁在给定的等待时间内是空闲的,并且当前线程没有被中断,则获取锁。
public boolean tryLock(long time, TimeUnit unit);
// 释放锁
public void unlock();

ReadWriteLock

ReadWriteLock维护一对关联的锁,一个用于只读操作,一个用于写操作。只要没有写线程,读锁可以被多个读线程同时持有。写锁是排他的。

所有的ReadWriteLock实现必须保证writeLock操作的内存同步效果(在Lock接口中指定)也与相关的readLock保持一致。也就是说,一个成功获得读锁的线程将看到在前一次释放写锁时所做的所有更新。

与互斥锁相比,读写锁在访问共享数据时允许更高级别的并发性。它利用了这样一个事实:虽然每次只有一个线程(写线程)可以修改共享数据,但在许多情况下,任何数量的线程都可以并发读取数据(因此是读线程)。从理论上讲,使用读写锁所允许的并发性提高将比使用互斥锁带来性能上的改进。在实践中,只有在多处理器上才能完全实现并发性的提高,而且只有在共享数据的访问模式合适的情况下才能实现。

读写锁是否会提高性能的使用互斥锁的频率取决于读取数据被修改相比,读和写操作的持续时间,争用数据——也就是说,线程的数量,将尝试读或写数据在同一时间。例如,如果一个集合最初填充了数据,然后在频繁地搜索(例如某种目录)时很少修改它,那么它是使用读写锁的理想候选对象。但是,如果更新变得频繁,那么数据的大部分时间都被排他锁定,并且并发性几乎没有增加。此外,如果读操作太短,读写锁实现的开销(这比互斥锁本身更复杂)可能会主导执行成本,特别是许多读写锁实现仍然通过一小段代码序列化所有线程。最终,只有分析和测量才能确定读写锁的使用是否适合您的应用程序。

尽管读写锁的基本操作很简单,但是实现必须做出许多策略决策,这可能会影响给定应用程序中读写锁的有效性。这些政策的例子包括:

1)确定在写入锁释放时,当读写器和写入器都在等待时,是授予读锁还是授予写锁。作者的偏好是常见的,因为写作被期望是短的和不频繁的。读者偏好不太常见,因为如果读者像预期的那样频繁且寿命很长,那么它会导致写操作的长时间延迟。公平或“顺序”实现也是可能的。

2)确定在读取器处于活动状态且写入器正在等待时请求读锁的读取器是否被授予读锁。对读取器的首选项可以无限期地延迟写入器,而对写入器的首选项可以降低并发性的可能性。

3)确定锁是否可重入:具有写锁的线程可以重新获取它吗?它能在持有写锁的同时获得读锁吗?读锁本身是可重入的吗?

4)写锁是否可以降级为读锁,而不允许有写入器介入?是否可以将读锁升级为写锁,而不是将其他等待的读或写锁?

在评估给定实现对应用程序的适用性时,应该考虑这些因素。

同样这个ReadWriteLock也是一个接口,但只提供了两个方法

// 返回一个用于读取的锁。
public Lock readLock();
// 返回一个用于写入的锁。
public Lock writeLock();

ReentrantLock

可重入互斥锁,具有与使用 synchronized 语句访问的隐式监视锁相同的基本行为和语义,但具有扩展的功能。

ReentrantLock属于上次成功锁定但尚未解锁的线程。当锁不属于另一个线程时,调用锁的线程将成功地获得锁。如果当前线程已经拥有锁,该方法将立即返回。可以使用方法isHeldByCurrentThread()和getHoldCount()进行检查。

该类的构造函数接受一个可选的公平性参数。当设置为true时,在争用情况下,锁有利于将访问权授予等待时间最长的线程。否则,这个锁不能保证任何特定的访问顺序。由多个线程访问的使用公平锁的程序可能会显示较低的总体吞吐量(例如较慢;通常比使用默认设置的锁慢得多),但获得锁的时间差异较小,并保证不会出现供应不足。但是请注意,锁的公平性不能保证线程调度的公平性。因此,使用公平锁的多个线程中的一个可能连续多次获得它,而其他活动线程没有继续进行,当前没有持有该锁。还要注意,非定时的tryLock()方法不支持公平性设置。如果锁可用,即使其他线程正在等待,它也会成功。

推荐的做法是,总是在调用之后立即使用try块锁定,最典型的是在before/after构造中,例如:

class X {
   private final ReentrantLock lock = new ReentrantLock();
   // ...
   public void m() {
     lock.lock();  // block until condition holds
     try {
       // ... method body
     } finally {
       lock.unlock()
     }
   }
 }

除了实现Lock接口之外,这个类还定义了许多用于检查锁状态的公共和保护方法。其中一些方法只对检测和监视有用。

这个类的序列化与内置锁的行为方式相同:反序列化的锁处于未锁定状态,而不管序列化时它的状态如何。

这个锁支持同一个线程最多2147483647个递归锁。试图超过此限制会导致锁定方法抛出Error。

类图

构造函数&方法 

private final Sync sync;

// 创建一个ReentrantLock实例。
public ReentrantLock() {
    sync = new NonfairSync();
}
// 创建一个具有给定公平策略的ReentrantLock实例。
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

// ======一些个常用的方法======

// 查询当前线程对该锁的持有数。
public int getHoldCount(){}
// 返回当前拥有该锁的线程,如果没有,则返回null。
public protected Thread getOwner(){}
// 返回一个集合,其中包含可能正在等待获得此锁的线程。
public protected Collection<Thread> getQueuedThreads(){}
// 返回等待获得此锁的线程的估计数目。
public int getQueueLength(){}
// 此锁是否为公平锁。是返回true。
public boolean isFair (){}
// 查询该锁是否被任何线程持有。
public boolean isLocked(){}
// 获取锁
public void lock(){}
// 仅当锁在调用时未被其他线程持有时,才获取锁。
public boolean tryLock(){}
// 释放锁
public void unlock(){}

ReentrantLock 是一个可重入的独占锁。底层是使用AQS(AbstractQueuedSynchronizer)来实现的。根据参数来决定锁的公平和非公平策略。

因为在构建这个实例的时候返回的是 sync,而sync则直接继承了AQS;

通过AQS的状态值来决定锁是否已经被占用。值为0则表示空闲,为1则说明锁已经被占用。其内部有公平和非公平实现,默认非公平。

由于这个锁是独占锁,故只能在某一个时间只能有一个线程获取该锁。

解决线程安全的问题,我们使用ReentrantLock就可以了,但它是独占锁,在实际当中,大部分都是写少读多的场景,那它就不太满足这个需求了,所以就出现了另一个锁

ReentrantReadWriteLock,采用读写分离的方式,允许多个线程同时获取读锁。

ReentrantReadWriteLock

ReadWriteLock的实现,支持与ReentrantLock相似的语法。有以下属性:

1)公平性

2)可重入

3)锁降级

4)锁中断获取

5)条件支持

6)状态监控

这个类的序列化与内置锁的行为方式相同:反序列化的锁处于未锁定状态,而不管序列化时它的状态如何。

示例用法:下面是一个代码草图,展示了如何在更新一个缓存后执行锁降级(当以非嵌套的方式处理多个锁时,异常处理特别棘手):

class CachedData {
   Object data;
   volatile boolean cacheValid;
   final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

   void processCachedData() {
     rwl.readLock().lock();
     if (!cacheValid) {
       // Must release read lock before acquiring write lock
       rwl.readLock().unlock();
       rwl.writeLock().lock();
       try {
         // Recheck state because another thread might have
         // acquired write lock and changed state before we did.
         if (!cacheValid) {
           data = ...
           cacheValid = true;
         }
         // Downgrade by acquiring read lock before releasing write lock
         rwl.readLock().lock();
       } finally {
         rwl.writeLock().unlock(); // Unlock write, still hold read
       }
     }
     try {
       use(data);
     } finally {
       rwl.readLock().unlock();
     }
   }
 }

ReentrantReadWriteLock可以用于提高某些类型集合的某些使用中的并发性。只有当期望集合很大、读线程多于写线程访问集合、操作开销大于同步开销时,才值得这样做。例如,这里有一个使用TreeMap的类,该类预计会很大,并且可以并发访问。

class RWDictionary {
   private final Map<String, Data> m = new TreeMap<String, Data>();
   private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
   private final Lock r = rwl.readLock();
   private final Lock w = rwl.writeLock();

   public Data get(String key) {
     r.lock();
     try { return m.get(key); }
     finally { r.unlock(); }
   }
   public String[] allKeys() {
     r.lock();
     try { return m.keySet().toArray(); }
     finally { r.unlock(); }
   }
   public Data put(String key, Data value) {
     w.lock();
     try { return m.put(key, value); }
     finally { w.unlock(); }
   }
   public void clear() {
     w.lock();
     try { m.clear(); }
     finally { w.unlock(); }
   }
 }

实现注意:该锁最多支持65535个递归写锁和65535个读锁。试图超过这些限制会导致锁定方法抛出Error。

构造函数&方法

// 创建一个带有默认(不公平)排序属性的新的ReentrantReadWriteLock。
public ReentrantReadWriteLock() {
    this(false);
}
// 使用给定的公平策略创建一个新的ReentrantReadWriteLock。
public ReentrantReadWriteLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
    readerLock = new ReadLock(this);
    writerLock = new WriteLock(this);
}

// 获取一个写锁
public ReentrantReadWriteLock.WriteLock writeLock() { 
    return writerLock; 
}
// 获取一个读锁
public ReentrantReadWriteLock.ReadLock  readLock()  { 
    return readerLock; 
}
// 返回当前拥有该锁的线程,如果没有,则返回null。
public protected Thread getOwner(){}
// 返回一个集合,其中包含可能正在等待获得此锁的线程。
public protected Collection<Thread> getQueuedThreads(){}
// 返回等待获得此锁的线程的估计数目。
public int getQueueLength(){}
// 此锁是否为公平锁。是返回true。
public boolean isFair (){}
// ... 

其实读写锁的内部就是维护了一个ReadLock和WriteLock,同样依赖于 Sync 来实现。这个Sync同样也继承了AQS,提供了公平和非公平的实现。

AQS使用 state 值的高16位表示获取到读锁的个数,低16位表示获取写锁的可重入次数,并通过CAS对其进行操作实现了读写分离。

这种情况在读多写少的场景下比较实用。

CAS

在Java里,锁在处理并发问题的时候占据了一席之地,但是锁有个不好的地方,就是当一个线程没有获取到锁的时候会被阻塞挂起(虽然有一些锁优化),如果一直没获取到,最终是逃不过的。那么就会导致线程上下文切换和重新调度的开销。虽然提供了非阻塞的volatile关键字解决共享变量的可见性问题,虽然在一定程度上弥补了锁带来的开销问题,但不能解决读改写这样的原子性问题。就又提供了 CAS(Compare and Swap 即比较交换)这种非阻塞原子性操作。

通过硬件保证了比较--更新操作的原子性。也就是JDK里 Unsafe 类提供的一系列 compareAndSwap* 方法;

cas的主要工作原理就是借助硬件处理器所停工的原子性命令;cas主要依赖四个操作数,分别为:对象内存位置、对象中的变量的偏移量、变量预期值和新值,其操作含义是:如果对象obj中内存偏移量为valueOffset的变量值为expect,则使用新值update替换就的值expect。

那么关于这个CAS操作也有一个经典的ABA问题,假如有一个变量X,初始值为A,线程1使用cas去操作这个变量将其改为B,那么首先会获取当前X变量的A,操作之后,然后去修改为B。如果这个操作正常完成了,那么程序就一定是正确的吗?其实未必。

因为有可能在线程1获取X变量的A之后,做操作的过程中,线程2去获取了X的变量A之后一顿操作之后改成了B,然后又改成了A。所以现在X变量的A已经不是线程1获取那个时候的A了,也就是此A非彼A。同时就造成了经典的ABA问题。

那是怎么解决这个问题的呢?就是JDK中的AtomicStampedReference类给每个变量的状态值都设置了时间戳,从而来避免ABA问题的发生。


;