并发编程Part 2

1. JMM

问题:请你谈谈你对volatile的理解?
volitile 是 Java 虚拟机提供的一种轻量级的同步机制 ,三大特性:
  1. 保证可见性
  2. 不保证原子性
  3. 禁止指令重排
线程之间如何通信? 
  • 通信是指线程之间以如何来交换信息。
  • 一般线程之间的通信机制有两种:共享内存消息传递
  • Java的并发采用的是共享内存模型~
什么是JMM?  
  • Java内存模型即JMM(Java Memeory Model),是Java虚拟机规范中所定义的一种内存模型,它并不是真实存在的,而是Java中抽象出来的一个概念,用于多线程场景。
  • 共享内存模型 指的就是 Java内存模型 ( 简称 JMM)   { Java多线程内存模型 }
  • JMM 本身是一种抽象的概念,并不真实存在,它描述的是一组规则或者规范~
  • JMM描述了一组规则或规范,定义了一个线程对共享变量写入时对另一个线程是可见的,从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性。
  • JMM描述了Java程序中各种线程共享变量(比如Java的实例对象、类变量、数组等,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题)的访问规则,以及在JVM中将变量存储到内存和内存中读取变量这样的底层细节。
  • 关于JMM的一些同步的约定:
  1. 线程解锁前,必须把共享变量立刻刷回主存。
  2. 线程加锁前,必须读取主存中的最新值到工作内存中!
  3. 加锁和解锁必须是同一把锁
  • JMM把内存分为两块,一块是私有线程的工作区域(工作内存或本地内存),一块儿是所有线程的共享区域(主内存)=> 计算机中的RAM。
  • 在JMM中把多个线程间通信的共享内存称之为主内存{Main Memory}线程之间的共享变量存储在主内存(Main Memory)中,每一个线程对共享变量都能访问(结论:主内存当中存储的这些共享变量可能会出现线程安全问题),而在并发编程中每个线程都维护自己的一个本地内存{工作内存},每个线程都有一个私有的本地内存(Local Memory)或工作内存,主要用于存储线程内的私有数据并且每个线程只能访问自己的工作内存,不同线程之前不能直接访问对方工作内存中的变量,工作内存中的数据是对每个线程私有的(结论:工作内存中的数据不存在线程安全问题),工作内存其中保存的数据是从主内存中拷贝过来的数据副本,线程对变量的所有操作(读、写)都必须在工作内存中完成,线程间变量的值的传递需要通过主内存完成,线程跟线程之间是相互隔离的,线程跟线程交互需要通过主内存来去共享数据,而JMM主要是控制本地内存和主内存之间的数据交互;
  • 本地内存是 JMM 的一个抽象概念,并不真实存在。
JMM内存模型
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量

下面通过示意图来说明线程之间的通信: 

总结:
  • 什么是Java内存模型:Java内存模型简称JMM,定义了一个线程对另一个线程可见。
  • 共享变量存放在主内存中,每个线程都有自己的本地内存,当多个线程同时访问一个数据的时候,可能本地内存没有及时刷新到主内存,所以就会发生线程安全问题。

怎样保证线程B可以同步感知线程A修改了共享变量呢?{保证可见性}

  1. 使用volatile关键字修饰变量
  2. 使用synchronized修饰修改变量的方法
  3. wait/notify
  4. while轮询
JMM内存模型底层八大原子操作: 

缓存一致性协议(MESI)

2. Volatile

volitile 是 Java 虚拟机提供的一种轻量级的同步机制 ,三大特性:
  1. 保证可见性
  2. 不保证原子性
  3. 禁止指令重排,从而避免多线程环境下程序中出现乱序执行的现象

使用volatile可以保证线程间共享变量的可见性,详细的说就是符合以下两个规则:

  • 线程对共享变量进行修改之后,要立刻回写到主内存。
  • 线程对共享变量读取的时候,要从主内存中读,而不是缓存。
  • 对于可见性,Java提供了volatile关键字来保证多线程对共享变量可见性和禁止JVM指令重排,volatile提供happens-before{先行发生}的保证,volatile变量可以确保先行关系即写操作会发生在后续的读操作之前,确保一个线程的修改能对其它线程是可见的当一个共享变量被volatile关键字修饰时,它会保证修改的值会立即被更新到主内存中,当有其它线程需要读取时,它会从主内存中读取最新的值。
  • {volatile关键字为变量的访问提供了一种免锁机制}
  • 但是,volatile并不能保证原子性例如用volatile修饰count变量,那么count++操作就不是原子性的{因为首先count++本身就不是原子性操作}
  • java.util.concurrent.atomic包下的原子类提供的atomic方法可以保证对其进行原子性操作,比如AtomicInteger类提供的getAndIncrement()方法会原子性的进行增量操作把当前值加1。
2.2 Volatile为什么可以保证可见性和有序性? - Volatile的底层原理 
  • 由于底层是通过操作系统的内存屏障来实现的,所以Volatile会禁止指令重排,因此同时也就保证了有序性。
  • 用Volatile修饰的共享变量会在读、写共享变量时加入不同的屏障,防止其它读写操作越过屏障,从而达到阻止重排序的效果。
2.3 指令重排序与内存屏障 
  • 并发编程三大特性:原子性,可见性,有序性
  • Volatile保证可见性与有序性,但是不能保证原子性,保证原子性需要借助synchronized这样的锁机制
什么是重排序?
  • CPU处理器为了提高程序运行效率,可能会对输入代码进行优化,对指令进行重新排序{重排序},它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
  • CPU处理器在进行指令重排序时必须要考虑数据之间的依赖性!{举例:一个后续指令要读取一个前面指令写入的数据,那么CPU处理器会确保这两个指令的顺序保持一致。}

指令重排可能带来的问题:

  • 指令重排对单线程运行时不会有任何问题,但是多线程就不一定了,可能会导致多线程程序出现内存可见性、有序性、死锁和活锁问题。
  • 可见性问题:指令重排可能导致变量的更新在某个线程中不可见,即一个线程对共享变量的更新操作对其它线程来说不可见,这可能会导致数据不一致性问题。
  • 有序性问题:指令重排可能会改变操作的执行顺序,从而引起线程之间的有序性问题。在多线程环境下,线程依赖于特定的操作顺序来保证正确的逻辑和数据一致性,指令重排可能会破坏这种有序性。
  • 死锁和活锁问题:指令重排可能导致线程在加锁和解锁操作上出现问题{可能导致锁的获取和释放顺序发生变化},进而引发死锁或活锁。

重排序实际执行的指令步骤 :

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

重排序会遵循as-if-serial和happens-before原则! 

2.4 as-if-serial语义
  • as-if-serial语义的意思是:不管怎么重排序{编译器和处理器为了提高并行度},线程执行的结果不能被改变{与单线程执行结果一致}
  • 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器做重排序。 

2.5 happens-before原则

JDK1.5开始,Java开始使用新的JSR-133内存模型,提供了happens-before{先行发生}原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,它提供了8组规则,通过这些规则可以确定操作之间是否存在happens-before关系。

happens-before原则内容如下{简单说明四点}:

  • 程序的顺序性原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行
  • 锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)操作之前
  • volatile规则:volatile变量的写操作,先发生于读操作,这保证了volatile变量的可见性,简单理解就是:volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能看到该变量的最新值。 
  • 传递性规则:A先于B,B先于C => 那么A必须先于C

总结:as-if-serial语义和happens-before规则这么做的目的,都是为了在不改变程序执行结果的             前提下,尽可能地提高程序执行的并行度。 

2.6 内存屏障
  • 内存屏障(Memeory Barrier)是一种与CPU相关的特殊指令{CPU指令}。
  • 内存屏障可以避免指令重排、保证内存操作的可见性。
  • 在Java中,内存屏障的概念被抽象为不同的同步机制,如volatile关键字和synchronized关键字,当使用这些关键字时,编译器和JVM会插入对应的内存屏障指令,以确保内存操作的有序性和可见性。
  • 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化{禁止上面指令和下面指令顺序交换}。
内存屏障分为两种:Load Barrier - 读屏障 和 Store Barrier - 写屏障:

2.7 synchronized 和 volatile 的区别是什么?
  • synchronized 表示同一时间只有一个线程可以获取作用对象的锁,去进入同步代码块或者同步方法来执行代码,其他线程会被阻塞。
  • volatile 表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性;禁止指令重排序。
区别:
  • volatile 是变量修饰符,volatile关键字只能用于变量;synchronized 关键字可以修饰方法以及代码块
  • volatile 仅能实现变量的修改可见性,不能保证原子性而 synchronized 则可以保证对变量的修改操作的可见性和原子性{加锁和解锁}。
  • volatile 不会造成线程的阻塞;synchronized 会造成其它线程的阻塞。
  • volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。
补充:临界区{有线程安全问题的代码块}

提问:Volatile的内存屏障在哪个地方使最多?

  • 在单例模式里面使用最多{懒汉式单例下面的 - 双重检测锁DCL方式实现单例模式}

3. 彻底玩转单例模式

双检锁懒汉式单例

双重检测锁/双重检验锁/双重检查锁(Double Checked Locking) DCL模式的懒汉式单例{线程安全},就是两次检查加一把锁

package com.gch.dcl;

/**
   DCL双重检验锁机制在多线程环境下实现延迟加载和线程安全的懒汉式单例模式{懒加载}
 */
public class SingleInstance {
    /**
       1.构造器私有
     */
    private SingleInstance() {

    }

    /**
       2.定义一个私有的用volatile修饰的静态成员变量存储一个对象 => 保证内存可见性防止JVM指令重排
     */
    private static volatile SingleInstance instance;

    /**
       3.提供一个方法,对外返回单例对象
     */
    public static SingleInstance getInstance(){
        /** 双重检验锁的逻辑式通过两次检查instance变量是否为null来实现延迟加载 */
        // 先判断对象是否已经被实例过{第一次检查 => 第一次检查可有可无}
        if(instance == null){
            // 对象没有被实例化过,进入加锁代码{类对象加锁}
            synchronized(SingleInstance.class){
                // 第二次检查 => 但是第二次检查必须要有
                // 为什么加第二次检查?
                // 这是为了防止多个线程同时通过第一次检查,然后先后进入synchronized同步代码块去创建实例的情况。
                // 说白了第二次检查是为了保证避免重复创建对象,加锁的目的是保证线程安全
                if(instance == null){
                    // 实例化 => 不是一个原子性操作
                    /**
                     * 1.为对象分配内存空间 => allocate() 
                     * 2.执行构造方法,初始化对象 => ctorInstance() - <init>
                     * 3.将对象指向分配的内存空间,将内存空间的地址赋值给对应的对象引用 => 返回内存地址给对象引用
                     */
                    instance = new SingleInstance();
                }
            }
        }
        return instance;
    }
}

验证第二次检查的必要性:

  • 我把第二次检查的代码注释掉,会发现两个线程分别创建返回了两个不同的对象,这显然违背了单例模式的原则~!

  • instance = new SingleInstance()操作不是原子的(对象的构造过程,实例化一个对象它不是一个原子操作,它是要分为三个步骤),分成 3 步执行,所以必须加volatie关键字防止JVM指令重排序~!

  • 由于JVM具有指令重排的特性,执行顺序有可能变成1 -> 3 -> 2。
  • 指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例,多线程环境下就可能将一个未被初始化的对象引用暴露出来,从而导致不可预料的结果。
  • 例如:线程T1执行了1和3,此时线程T2调用了getInstance()方法后方法instance不为空,因此直接返回instance,但此时instance变量还未被初始化。
  • 因此instance变量使用volatile关键字修饰很有必要,可以禁止JVM的指令重排,保证在多线程环境下也能正常运行

通过反射暴力破解单例模式:

package com.gch.dcl;

import java.lang.reflect.Constructor;

public class Main {
    public static void main(String[] args) throws Exception {
        // 1.获取Class字节码文件的对象
        Class<SingleInstance> clazz = SingleInstance.class;
        // 2.获取到空参的构造方法
        Constructor<SingleInstance> constructor = clazz.getDeclaredConstructor();
        // 3.暴力反射,表示临时取消访问权限,表示权限被打开
        constructor.setAccessible(true);
        // 4.利用获取到的构造方法去创建对象
        SingleInstance instance1 = constructor.newInstance();
        SingleInstance instance2 = constructor.newInstance();
        System.out.println(instance1);
        System.out.println(instance2);
        System.out.println(instance1 == instance2);
    }
}

使用枚举实现单例 - 通过枚举防止反射破解创建实例对象:

  • 不能使用反射破坏枚举 

  • 枚举类的实例是在类加载时被初始化的,而且枚举类不支持反射{创建实例},因此使用枚举类实现的单例模式是天然防止反射破解的。

使用枚举实现单例模式的优点:
  1. 线程安全:枚举的实例在任何情况下都是唯一的,不需要担心多线程环境下的竞争条件。
  2. 防止反射:枚举类不支持反射,可以防止通过反射破解单例模式。

枚举饿汉式单例:

  • 枚举饿汉式能天然防止反射、反序列化破坏单例

package com.gch.single.instance;

/**
 * 枚举饿汉式单例
 * 由于Java的枚举类型天生支持单例属性,因此通过枚举类型来实现单例是非常简单可靠的
 * 通过SingleInstanceEnum.INSTANCE来获取单例对象
 */
// 1.定义枚举类,枚举类型中的每个值都是一个单例对象
public enum SingleInstanceEnum {
   // 2.定义一个枚举元素,即单例对象
   INSTANCE;
}
package com.gch.dcl;

import java.lang.reflect.Constructor;

/**
   不能使用反射破坏枚举,枚举不支持反射
 */
public enum SingleInstanceEnum {
    INSTANCE;
}

class Test{
    public static void main(String[] args) throws Exception {
        SingleInstanceEnum instance1 = SingleInstanceEnum.INSTANCE;
        SingleInstanceEnum instance2 = SingleInstanceEnum.INSTANCE;
        SingleInstanceEnum instance3 = SingleInstanceEnum.INSTANCE;
        System.out.println(instance1);
        System.out.println(instance2);
        System.out.println(instance3);

        /**
           尝试使用反射破坏枚举 => 运行时报错java.lang.NoSuchMethodException
         */
        // 1.获取Class字节码文件的对象
        Class<SingleInstanceEnum> clazz = SingleInstanceEnum.class;
        // 2.获取到空参的构造方法
        Constructor<SingleInstanceEnum> constructor = clazz.getDeclaredConstructor();
        // 3.暴力反射,表示临时取消访问权限,表示权限被打开
        constructor.setAccessible(true);
        // 4.利用获取到的构造方法去创建对象
        SingleInstanceEnum instance4 = constructor.newInstance();
        SingleInstanceEnum instance5 = constructor.newInstance();
        System.out.println(instance4);
        System.out.println(instance5);
    }
}

枚举反编译其实是有一个私有构造器,并且是有两个参数

4. 深入理解CAS

代码演示: 

package com.gch.cas;

import java.util.concurrent.atomic.AtomicInteger;

/**
   CAS
 */
public class CasDemo {
    private static AtomicInteger atomicInteger = new AtomicInteger(2020);
    public static void main(String[] args) {
     //  public final boolean compareAndSet(int expectedValue, int newValue)
        // 如果我期望的值达到了,那么就更新;否则,就不更新    CAS是CPU的并发原语!

        // false
        System.out.println(atomicInteger.compareAndSet(2020, 2023));
        // 2023
        System.out.println(atomicInteger);

        // false
        System.out.println(atomicInteger.compareAndSet(2020, 2018));
        // 2023
        System.out.println(atomicInteger);
    }
}

总结:真实值与期望值相同,就修改成功;真实值与期望值不同,就修改失败!

4.1 atomicInteger.getAndIncrement(); 这里的自增 + 1怎么实现的?
  • 涉及到UnSafe类,谈谈你对UnSafe类的理解?
1、UnSafe
  • UnSafe类是CAS的核心类,由于Java方法无法直接操作硬件,无法直接访问底层系统,需要通过本地(native)方法来访问,UnSafe相当于一个后门,基于该类可以直接操作特定内存的数据Unsafe类存在于 sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中CAS操作的执行依赖于Unsafe类的方法
  • 注意:Unsafe类中的所有方法都是Native修饰的,也就是说Unsafe类中的方法都直接               调用操作系统底层资源执行相应任务!
  • 最后解释CAS 是什么?
  • CAS 的全称为 Compare-And-Swap它是一条CPU并发原语。
  • 它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。
  • CAS并发原语体现在JAVA语言中就是 sun.misc.Unsafe 类中的各个方法。调用UnSafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。
总结:
CAS(CompareAndSwap)
  • 比较当前工作内存中的值和主内存中的值,如果相同则执行修改操作,否则继续比较直到主内存和工作内存中的值一致为止。
CAS 应用
  • CAS 3个操作数,内存值V,旧的预期值A,要修改的更新值B。且仅当预期值A 和 内存值 V 相同时,将内存值 V 修改为B,否则什么都不做。
CAS的缺点:
  1. ABA问题:即在并发情况下,一个值从A变为B,然后再从B变回A,但是期间可能有其它线程操作对该值进行修改,这样CAS操作在判断时可能会出现误判,误认为它从来没有被修改过,但是实际上是有变化的。
  2. 循环开销时间长:源码中存在一个do...while...循环操作,如果CAS操作失败就会导致一直进行尝试自旋,给CPU带来非常大的开销。
  3. 只能保证一个共享变量的原子操作:对一个共享变量执行操作时,CAS能够保证原子性操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的,这个时候就可以用锁来保证原子性。


5. 原子引用 - AtomicReference{带版本号的原子操作}

CAS => UnSafe => CAS 底层思想 => ABA => 原子引用更新 => 如何规避ABA问题

CAS操作会导致"ABA"问题,如何规避"ABA"问题?

  • JDK1.5开始提供的AtomicStampedReference类通过原子引用更新来解决"ABA"问题。

原子引用 - AtomicReference

AtomicReference类

AtomicStampedReference类
  • AtomicReference和AtomicStampedReference是Java中的两个原子类,用于实现线程安全的操作。
  • AtomicReference类提供了对对象引用的原子操作,它可以保证在并发环境下对对象引用的操作是线程安全的。
  • AtomicStampedReference是AtomicReference的一个扩展,它在原子引用的基础上,还提供了一个表示引用的整数字段,该标记用于区分引用的不同版本。使用AtomicStampedRefernence通过比较引用和标记的值,来判断引用是否发生了变化,从而避免误判,解决了CAS操作时可能会出现的"ABA"问题。
  • 因此,AtomicStampedReference相比于AtomicReference提供了更强的版本控制和解决ABA问题的能力,两者的联系在于,AtomicStampedReference是对AtomicReference的功能的扩展,提供了更高级别的引用版本控制。
package com.gch.cas;

import java.util.concurrent.atomic.AtomicStampedReference;

/**
   原子类AtomicStampedReference
 */
public class AtomicStampedReferenceExample {
    public static void main(String[] args) {
        // 创建一个初始引用为2020,初始标记为0的AtomicStampedReference对象
        AtomicStampedReference<Integer> asr = new AtomicStampedReference<>(2020,0);
        
        /**
           注意:泛型为Integer,那么获取当前引用的接收参数也必须为Integer类型,不能写int,否则CAS会失败!
         */

        // 获取当前引用和标记
        Integer currentReference = asr.getReference();
        int currentStamp = asr.getStamp();
        System.out.println("当前引用:" + currentReference + "   当前标记:"  + currentStamp);

        // 尝试将引用从2020修改为2023,标记+1
        int newStamp = currentStamp + 1;
       // public boolean compareAndSet (V expectedReference, V newReference,int expectedStamp, int newStamp)
        boolean result = asr.compareAndSet(currentReference,2023,currentStamp,newStamp);
        System.out.println(result == true ? "修改成功" : "修改失败!");

        // 获取修改后的引用和标记
        Integer updateReference = asr.getReference();
        int updateStamp = asr.getStamp();
        System.out.println("修改后的引用:" + updateReference + "   修改后的标记:" + updateStamp);

        // 尝试将因引用2023修改回2020,标记+1
        int anotherStamp = updateStamp + 1;
        boolean newResult = asr.compareAndSet(updateReference,2020,updateStamp,anotherStamp);
        System.out.println(newResult == true ? "修改成功" : "修改失败!");

        // 获取恢复后的引用和标记
        Integer restoredReference = asr.getReference();
        int restoredStamp = asr.getStamp();
        System.out.println("恢复后的引用:" + restoredReference + "  恢复后的标记:" + restoredStamp);
    }
}


 

6. 各种锁的理解

6.1 公平锁 VS 非公平锁
  • 默认情况下,锁都是非公平的,因为非公平锁的性能要比公平锁的性能更好!

公平锁: 

  • 公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入等待队列中排队,队列中的第一个线程才能获得锁。
  • 公平锁的优点是等待锁的线程不会饿死。
  • 缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,都需要CPU去唤醒,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁: 

  • 非公平锁中,线程会先尝试插队(获取锁):插队成功的线程直接获取了锁,没有进入等待队列也不会阻塞,CPU不需要增加唤醒工作,吞吐量提高;插队失败时,线程需要进入等待队列排队并阻塞,CPU需要增加唤醒工作,此时的吞吐量等同于公平锁。
  • 非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。
  • 非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
  • 缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。 

接下来我们通过ReentrantLock的源码来讲解公平锁和非公平锁:

  • ReentrantLock主要利用CAS + AQS队列来实现。
ReentrantLock源码
  •  根据代码可知,ReentrantLock里面有一个内部类SyncSync继承AQS(AbstractQueuedSynchronizer)=>  Sync的父类是AQS,添加锁和释放锁的大部分操作实际上都是在Sync中实现的。它有公平锁FairSync和非公平锁NonfairSync两个子类ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。

构造方法接收一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁,公平锁的效率往往没有非公平锁的效率高,在多线程访问的情况下,公平锁表现出较低的吞吐量。 

ReentrantLock源码中的构造方法:

提供了两个构造方法,不带参数的默认为非公平锁;如果使用了带参数的构造方法,并且传入的值为true,则是公平锁。 

其中NonfairSync和FairSync这两个类父类都是Sync

而Sync的父类是AQS,所以可以得出ReentrantLock底层主要实现就是基于AQS来实现的。

下面我们来看一下公平锁与非公平锁的加锁方法的源码:

  • 通过上图中的源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()

  • 再进入hasQueuedPredecessors(),可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个,如果是则返回true,否则返回false

综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

工作流程

  • 线程来抢锁后使用cas的方式修改state状态,修改状态成功为1,则让exclusiveOwnerThread属性指向当前线程,获取锁成功

  • 假如修改状态失败,则会进入双向队列中等待,head指向双向队列头部,tail指向双向队列尾部

  • 当exclusiveOwnerThread为null的时候,则会唤醒在双向队列中等待的线程

  • 公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁  

6.2 可重入锁 VS 非可重入锁
  • 可重入锁又名递归锁{防死锁设计},是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

下面用示例代码来进行分析:

package com.gch.lock;

public class Demo {
    public synchronized void doSomething(){
        System.out.println("方法1执行...");
        doOthers();
    }

    public synchronized void doOthers(){
        System.out.println("方法2执行...");
    }
}

class Main{
    public static void main(String[] args) {
        new Demo().doSomething();
    }
}

  • 在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。
  • 如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁,导致等待队列中的所有线程都无法执行。
  • 非可重入锁在重复调用同步资源时会出现死锁。

首先ReentrantLock中的静态内部类Sync继承父类AQS(AbstractQueuedSynchronizer),其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。

6.3 自旋锁 VS 适应性自旋锁
  • 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
  • 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。因为线程阻塞涉及到用户态和内核态切换的问题。既然同步资源的锁定时间很短{也就是synchronized代码块里面的代码执行的非常快},不妨让请求锁的线程不放弃CPU的执行时间{不要被阻塞},而是让当前线程进行自旋,在synchronized的边界做忙循环,看看持有锁的线程是否很快就会释放锁,如果在自旋完成后{做了多次循环后}前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁。
  • 忙循环:就是程序员用循环让一个线程等待,不像传统方法wait(),sleep()或yield()它们都放弃了CPU控制,而忙循环不会放弃CPU,它就是在运行一个空循环,这么做的目的就是为了保留CPU缓存,在多核系统中,一个等待线程醒来的时候可能会在另一个内核运行,这样会重新建立缓存{重建缓存}。忙循环的优点:为了避免重建缓存和减少等待重建的时间。

  • 自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度如果自旋超过了限定次数默认是10次可以使用-XX:PreBlockSpin来更改没有成功获得锁,就应当挂起线程。
  • 自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。 

  • 自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 1.6 中变为默认开启,但是无论你怎么调整这些参数,都无法满足不可预知的情况,于是JDK 1.6中引入了引入了自适应的自旋锁(适应性自旋锁),让虚拟机变得越来越聪明。
  • 自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
  • 如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
6.4 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁 
  • 这四种锁是指锁的状态,专门针对synchronized的。在介绍这四种锁状态之前还需要介绍一些额外的知识。

首先为什么Synchronized能实现线程同步?

  • 在回答这个问题之前我们需要了解两个重要的概念:“Java对象头”、“Monitor”。

Java对象头 

  • synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?
  • 我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
  • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  • Klass Point对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Monitor 

  • Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
  • Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
  • 现在话题回到synchronized,synchronized通过Monitor来实现线程同步Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。
  • 如同我们在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。 
  • 所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁锁状态只能升级不能降级。 

通过上面的介绍,我们对synchronized的加锁机制以及相关知识有了一个了解,那么下面我们给出四种锁状态对应的的Mark Word内容,然后再分别讲解四种锁状态的思路以及特点:

无锁 

  • 无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
  • 无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁 

  • 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
  • 在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
  • 当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
  • 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
  • 偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

轻量级锁

  • 轻量级锁的目标是,减少无实际竞争的情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换,线程阻塞造成的线程切换等。
  • 使用轻量级锁,不需要申请互斥量。
  • 是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
  • 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
  • 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
  • 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
  • 如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
  • 若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

重量级锁 

  • 自旋锁的目标是降低线程切换的成本,如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞。
  • 重量级锁使得除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
  • 轻量级锁膨胀之后,就升级为重量级锁了,重量级锁是依赖对象内部的Moniter锁来实现的,而Moniter又依赖于操作系统的Mutex互斥锁来实现的,所以重量级锁也被为互斥锁(阻塞同步、同步锁、悲观锁)。
  • 升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。 

为什么说重量级锁开销大呢? 

  • 主要是,当系统检测到锁是重量级锁之后,会把等待想要获取锁的线程进行阻塞,被阻塞的线程不会消耗CPU,但是阻塞或唤醒一个线程,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

总结:偏向锁、轻量级锁都是乐观锁,而重量级锁是悲观锁。 

整体的锁状态升级流程如下: 

  • 综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。
6.5 独享锁 VS 共享锁 
  • 独享锁(独占锁)也叫排他锁{互斥锁},是指该锁一次只能被一个线程所持有。 JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
  • 共享锁是指该锁可被多个线程所持有。获得共享锁的线程只能读数据,不能修改数据。 
  • 独占锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独占或者共享。 

下图为ReentrantReadWriteLock的部分源码:

  • 我们看到ReentrantReadWriteLock有两把锁:ReadLockWriteLock,由词知意,一个读锁,一个写锁,合称“读写锁”。再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。 
  • 在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。
  • 读锁的共享锁可保证并发读非常高效。
  • 因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。

读锁和写锁的具体加锁方式有什么区别呢? 

  • 在最开始提及AQS的时候我们也提到了state字段(int类型,32位),该字段用来描述有多少线程持有锁。 
  • 在独享锁中这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。但是在ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将state变量“按位切割”切分成了两个部分高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。如下图所示: 

6.6 死锁、活锁、锁饥饿
死锁  
  • 死锁是多线程中最差的一种情况,多个线程相互占用对方的资源的锁,而又相互等对方释放锁,此时若无外力干预,这些线程则一直处理阻塞的假死状态,形成死锁。

活锁 

  • 活锁恰恰与死锁相反,死锁是大家都拿不到资源都占用着对方的资源,而活锁是拿到资源却又相互释放不执行,当多线程中出现了相互谦让,都主动将资源释放给别的线程使用,这样这个资源在多个线程之间跳动而又得不到执行,这就是活锁。

锁饥饿 

  • 我们知道多线程执行中有线程优先级这个东西,优先级高的线程能够插队并优先执,这样如果优先级高的线程一直抢占优先级低线程的资源,导致低优先级线程无法得到执行,这就是饥饿
  • 当然还有一种饥饿的情况,一个线程一直占着一个资源不放而导致其他线程得不到执行,与死锁不同的是饥饿在以后一段时间内还是能够得到执行的如占用资源的线程结束了并释放了资源
6.7 死锁排查 

死锁代码示例:

package com.gch.lock;

/**
   死锁演示
 */
public class DeadLock {
    /**
       定义A锁,B锁
     */
    private static Object objA = new Object();
    private static Object objB = new Object();
    /**
       主函数
     */
    public static void main(String[] args) {
        /**
           两个线程相互等待对方释放锁,它们都无法继续执行,形成了死锁!
         */
        new Thread(()->{
            String name = Thread.currentThread().getName();
            synchronized(objA){
                System.out.println(name + " : Holding Lock A...");
                System.out.println(name + " : Waiting for Lock B...");
                synchronized(objB){
                    System.out.println(name + " : Holding Lock A and Lock B...");
                }
            }
        },"Thread A").start();

        new Thread(()->{
            String name = Thread.currentThread().getName();
            synchronized(objB){
                System.out.println(name + " : Holding LockB...");
                System.out.println(name + " : Waiting for Lock A...");
                synchronized(objA){
                    System.out.println(name + " : Holding Lock A and Lock B...");
                }
            }
        },"Thread B").start();
    }
}

死锁排查:

拓展java自带工具操作:
  1. 查看JDK目录的bin目录
  2. 使用 jps -l 命令定位进程号
  3. 使用 jstack + 进程号 查看Java进程内线程的堆栈信息,找到死锁查看

1. 使用 jps -l 命令定位进程号 

2.  使用 jstack + 进程号 查看Java进程内线程的堆栈信息,找到死锁查看

7. 其它知识补充 

1. 怎样检测一个线程是否拥有锁?

  • Thread.holdsLock()方法

2. JDK中排查多线程问题用什么命令?

  • jstack

3. 什么是阻塞式方法?

  • 阻塞式方法是指程序会一直等待该方法完成期间不做其它事情,ServerSokcet的accept()方法就是一直等待客户端连接,这里的阻塞是指调用结果返回之前,当前线程会被挂起,直到得到结果之后才会返回。 

4. Thead.sleep(0)有意义吗以及它有什么作用?

  • 本题考点:对Thread.sleep()底层实现原理的理解,涉及到操作系统、进程、线程调度和管理的理解。
  • Thread.sleep()方法是Java线程调度的一部分,它可以让当前运行的线程暂停执行并进入到阻塞状态,让出CPU的执行权,这个方法可以传入一个时间参数,让线程睡眠指定的时间,正常情况下,我们在使用Thread.sleep()这个方法的时候,会传入一个特定的时间。这个方法的底层是调用操作系统的sleep或者nanossleep系统调用,操作系统会把这个线程挂起,让出CPU的执行权给到其它的线程或者进程,同时操作系统会设置一个定时器,当定时器到了以后,操作系统会再次唤醒这个线程。
  • Thread.sleep(0)这个调用虽然没有传递睡眠时长,但实际上还是会触发线程调度的切换,也就是说当前线程会从运行状态变为就绪状态,然后操作系统的调度器再根据优先级来选择一个线程来执行,如果有优先级更高的线程正在等待CPU的时间片,那么这个线程就会得到执行;如果没有,那么可能就会立即再次选择刚刚进入这个就绪状态的线程来执行,具体的调度策略取决于操作系统层面的调度算法。

6. 用什么命令可以查看线程的生命周期?

  • jcosole

  • 从本质上来说,AQS提供了两种锁的机制,分别是排他锁和共享锁。
  • 所谓排他锁就是存在多个线程去竞争同一共享资源的时候,同一个时刻,只允许一个线程去访问共享资源,也就是说多个线程只能有一个线程去获得这样一个锁的资源,比如Lock中的ReentrantLock可重入锁,它的实现就是用到了AQS中的一个排他锁的功能。
  • 共享锁也称为读锁,就是在同一个时刻允许多个线程同时获得这样一个锁的资源,比如CountDownLatch以及Semaphore,都用到了AQS中的共享锁的功能。
  • 那么AQS作为互斥锁来说,它的整个设计体系中,需要解决三个核心的问题:第一个互斥变量的设计以及如何保证多线程同时更新互斥变量的时候线程的安全性;第二个未竞争到锁资源的线程的等待以及竞争到锁的资源,释放锁之后的唤醒;第三个锁竞争的公平性和非公平性,AQS采用了一个int类型的互斥变量"state"用来记录锁竞争的一个状态,0表示当前没有任何线程竞争到锁资源,而>=1,表示已经有线程正在持有锁资源,一个线程来获取锁资源的时候,首先会判断"state"是否等于0,也就是说它是无锁状态,如果是,则把state更新成1,表示占用到锁,而这个过程中,如果多个线程同时去做这样一个操作,就会导致线程安全性问题,因此AQS采用了CAS机制,去保证state互斥变量更新的一个原子性,未获得到锁的线程,通过Unsafe类中的park方法去进行阻塞,把阻塞的线程按照先进先出的原则去加入到一个双向链表的一个结构中,当获得锁资源的线程释放锁之后,会从这样一个双向链表的头部去唤醒下一个阻塞等待的线程再去竞争锁,最后关于锁竞争的公平性和非公平性的问题,AQS的处理方式是在竞争锁资源的时候,公平锁需要去判断双向链表中是否有阻塞的线程,如果有,则需要去排队等待;而非公平锁的处理方式是,不管双向链表中是否存在等待竞争锁的线程,那么它都会直接去尝试,更改互斥变量"state"去竞争锁,假设在一个临界点,获得锁的线程释放锁,此时state等于0,而当前的这个线程去抢占锁的时候,正好可以把state修改成1,那么这个时候就表示它可以拿到锁,而这个过程是非公平的,

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Surpass余sheng军

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

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

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

打赏作者

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

抵扣说明:

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

余额充值