Java多线程与并发-原理

9-1 synchronized 9-2 synchronized底层实现原理 9-3 synchronized和ReentrantLock 9-4 jmm的内存可见性 9-5 CAS 9-6 Java线程池

9-1 synchronized

synchronized 锁的不是代码,锁的是对象

  • 线程安全问题的主要诱因与解决方法

    • 存在共享数据(临界资源)
    • 穿在多条线程共同操作这些共享数据
    • 解决方法:同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作
  • 互斥锁的特性

    • 互斥性(原子性):即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块(复合操作)进行访问。互斥性也称为操作的原子性。
    • 可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作,从而引起不一致。
  • 根据获取的锁的分类:获取对象锁和获取类锁

    • 获取对象锁的两种用法
      1. 同步代码块(synchronized(this),synchronized(类实例对象)),锁市小括号()中的实例对象。
      2. 同步非静态方法(synchronized method),锁是当前对象的实例对象。
    • 获取类锁的两种用法
      1. 同步代码块(synchronized(类.class)),锁是小括号()中的类对象(Class 对象)。
      2. 同步静态方法(synchronized static method),锁是当前对象的类对象(Class 对象)。
    • 对象锁和类锁的总结
      1. 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块;
      2. 若锁住的市同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块的线程会被阻塞;
      3. 若锁住的时同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程会被阻塞;
      4. 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个对象访问对象同步方法的线程会被阻塞,反之亦然;
      5. 同一个类的不同对象的对象锁互不干扰;
      6. 类锁由于也是一种特殊的对象锁,因此表现和上述1、2、3、4一致,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁将会是同步的;
      7. 类锁和对象锁互不干扰。

9-2 synchronized底层实现原理

  • 实现synchronized的基础

    • Java 对象头
    • Monitor
  • 对象在内存中的布局

    • 对象头
    • 实例数据
    • 对齐填充
  • 对象头

    • 对象头结构

      虚拟机位数头对象结构说明
      32/64 bitMark Word默认存储对象的 hashCode,分代年龄,锁类型,锁标志位等信息
      32/64 bitClass Metadata Address类型指针指向对象的类元数据,JVM 通过这个指针确定该对象是哪个类的数据
    • Mark Word
      MarkWord

  • Monitor:每个 Java 对象天生带了一把看不见的锁

    • Monitor 锁的竞争、获取与释放

    Monitor 锁的竞争、获取与释放

package com.imocc.javabasic.bytecode.thread;

/**
 * @author zhangjingyu
 */
public class SyncBlockAndMethod {
    public void syncsTask() {
        //同步代码库
        synchronized (this) {
            System.out.println("Hello");
            synchronized (this){
                System.out.println("World");
            }
        }
    }
    public synchronized void syncTask() {
        System.out.println("Hello Again");
    }
}

javap -verbose 反编译

  Last modified 2020-2-28; size 715 bytes
  MD5 checksum 3e8851bf5c1e9084d338a4f1032246e2
  Compiled from "SyncBlockAndMethod.java"
public class com.imocc.javabasic.bytecode.thread.SyncBlockAndMethod
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #8.#21         // java/lang/Object."<init>":()V
   #2 = Fieldref           #22.#23        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #24            // Hello
   #4 = Methodref          #25.#26        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = String             #27            // World
   #6 = String             #28            // Hello Again
   #7 = Class              #29            // com/imocc/javabasic/bytecode/thread/SyncBlockAndMethod
   #8 = Class              #30            // java/lang/Object
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               syncsTask
  #14 = Utf8               StackMapTable
  #15 = Class              #29            // com/imocc/javabasic/bytecode/thread/SyncBlockAndMethod
  #16 = Class              #30            // java/lang/Object
  #17 = Class              #31            // java/lang/Throwable
  #18 = Utf8               syncTask
  #19 = Utf8               SourceFile
  #20 = Utf8               SyncBlockAndMethod.java
  #21 = NameAndType        #9:#10         // "<init>":()V
  #22 = Class              #32            // java/lang/System
  #23 = NameAndType        #33:#34        // out:Ljava/io/PrintStream;
  #24 = Utf8               Hello
  #25 = Class              #35            // java/io/PrintStream
  #26 = NameAndType        #36:#37        // println:(Ljava/lang/String;)V
  #27 = Utf8               World
  #28 = Utf8               Hello Again
  #29 = Utf8               com/imocc/javabasic/bytecode/thread/SyncBlockAndMethod
  #30 = Utf8               java/lang/Object
  #31 = Utf8               java/lang/Throwable
  #32 = Utf8               java/lang/System
  #33 = Utf8               out
  #34 = Utf8               Ljava/io/PrintStream;
  #35 = Utf8               java/io/PrintStream
  #36 = Utf8               println
  #37 = Utf8               (Ljava/lang/String;)V
{
  public com.imocc.javabasic.bytecode.thread.SyncBlockAndMethod();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public void syncsTask();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=5, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String Hello
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_0
        13: dup
        14: astore_2
        15: monitorenter
        16: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
        19: ldc           #5                  // String World
        21: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        24: aload_2
        25: monitorexit
        26: goto          34
        29: astore_3
        30: aload_2
        31: monitorexit
        32: aload_3
        33: athrow
        34: aload_1
        35: monitorexit
        36: goto          46
        39: astore        4
        41: aload_1
        42: monitorexit
        43: aload         4
        45: athrow
        46: return
      Exception table:
         from    to  target type
            16    26    29   any
            29    32    29   any
             4    36    39   any
            39    43    39   any
      LineNumberTable:
        line 6: 0
        line 7: 4
        line 8: 12
        line 9: 16
        line 10: 24
        line 11: 34
        line 12: 46
      StackMapTable: number_of_entries = 4
        frame_type = 255 /* full_frame */
          offset_delta = 29
          locals = [ class com/imocc/javabasic/bytecode/thread/SyncBlockAndMethod, class java/lang/Object, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
        frame_type = 68 /* same_locals_1_stack_item */
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 6

  public synchronized void syncTask();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #6                  // String Hello Again
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 15: 0
        line 16: 8
}
SourceFile: "SyncBlockAndMethod.java"
  • JDK6之前synchronized效率低下的原因:

    • 早期版本中,synchronized 属于重量级锁,依赖于 Mutex Lock 实现
    • 而操作系统切换线程时需要从用户态转换到核心态,时间较长,开销较大
  • java6以后Synchronized性能得到了很大提升(hotspot从jvm层面做了较大优化,减少重量级锁的使用):

    • Adaptive Spinning 自适应自旋
    • Lock Eliminate 锁消除
    • Lock Coarsening 锁粗化
    • Lightweight Locking 轻量级锁
    • Biased Locking偏向锁
    • 。。。
  • 自旋锁与自适应自旋锁

    • 自旋锁
      • 许多情况下,共享数据的锁定状态持续时间较短,切换线城不值得
      • 通过让线程执行忙循环等待锁的释放,不让出 CPU
      • 缺点:若锁被其他线程长时间占用,会带来许多性能上的开销
    • 自适应自旋锁:(java6引入,jvm对锁的预测会越来越精准,jvm也会越来越聪明)
      • 自选次数不再固定
      • 由前一次在同一个锁上的自旋时间及锁拥有者的状态来决定
  • 锁消除:jvm的另一种锁优化,更彻底的优化

    • JIT编译时,对运行上下文进行扫描,去除不可能存在的竞争的锁,消除毫无意义的锁
        public void add(String str1, String str2) {
            //StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
            //因此sb属于不可能共享的资源,JVM会自动消除内部的锁
            StringBuffer sb = new   StringBuffer();
            sb.append(str1).append(str2);
        }   
    
  • 锁粗化:另一种极端

    • 扩大加锁范围,避免反复的加锁和解锁
        public static String copyString100Times(String target){
            int i = 0;
            StringBuffer sb = new StringBuffer();
            while (i<100){
                //JVM 会只加一次锁
                sb.append(target);
            }
            return sb.toString();
        }
    
  • synchronized的四种状态

    • 无锁
    • 偏向锁:减少同一线程获取锁的代价
      • 大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得
      • 核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时MarkWord的结构也变成偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查MarkWord的锁标记位为偏向锁以及当前线程ID等于MarkWord的ThreadID即可,这样就省去了大量有关锁申请的操作。
      • 不适合用于锁竞争比较激烈的多线程场合
    • 轻量级锁
      • 轻量级锁是由偏向锁升级而来的,偏向锁运行再一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁
      • 适用场景:线程交替执行的同步块
      • 若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁
    • 重量级锁
    • 锁膨胀方向:无锁->偏向锁->轻量级锁->重量级锁
  • 锁的内存语义

    • 当线程释放锁时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中
    • 当线程获取锁时,Java内存模型会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
  • 偏向锁、轻量级锁、重量级锁的汇总

    优点缺点使用场景
    偏向锁加锁和解锁操作不需要 CAS 操作,没有额外的性能小号,和执行非同步方法相比仅存在纳秒级差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗只有一个线程访问同步块或者同步方法的场景
    轻量级锁竞争的线程不会阻塞,提高了响应速度若线程长时间抢不到锁,自旋会消耗 CPU 性能线程交替执行同步块或者同步方法的场景
    重量级锁线程竞争不使用自旋,不会消耗 CPU线程阻塞,响应时间换慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗追求吞吐量,同步块或者同步方法执行时间较长的场景

9-3 synchronized和ReentrantLock

  • ReentrantLock(可重入锁)

    • 位于java.util.concurrent.locks包
    • 和CountDownLatch、FutureTask、Semaphore一样基于AQS实现
    • 能够实现比synchronized更细粒度的控制,如控制fairness(公平性)
    • 调用lock()后,必须调用unlock()释放锁
    • 性能未必比synchronized高,并且也是可重入的
  • ReentrantLock公平性设置

    •   ReentrantLock fairLock = new ReentrantLock(true);
      
    • 参数为 true 时,倾向于将锁赋予等待时间最久的线程
    • 公平锁:获取锁的顺序按先后调用 lock 方法的顺序(慎用)
    • 非公平锁:抢占的顺序不一定,看运气
    package com.imocc.javabasic.bytecode.thread;
    
    import java.util.concurrent.locks.ReentrantLock;
    
    public class ReentrantLockDemo implements  Runnable{
        private static ReentrantLock lock = new ReentrantLock(false);
        @Override
        public void run(){
            while (true){
                lock.lock();
                System.out.println(Thread.currentThread().getName() + " get lock");
                try{
                    Thread.sleep(1000);
                } catch (Exception e){
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
            }
        }
    
        public static void main(String[] args) {
            ReentrantLockDemo rtld = new ReentrantLockDemo();
            Thread thread1 = new Thread(rtld);
            Thread thread2 = new Thread(rtld);
            thread1.start();
            thread2.start();
        }
    }
    //公平锁 new ReentrantLock(true);
    //Thread-0 get lock
    //Thread-1 get lock
    //Thread-0 get lock
    //Thread-1 get lock
    
    //非公平锁 new ReentrantLock(false);
    //Thread-0 get lock
    //Thread-0 get lock
    //Thread-0 get lock
    //Thread-0 get lock
    
  • ReentrantLock将锁对象化

    • 判断是否有线程,或者某个特定线程再排队等待获取锁
    • 带超时的获取锁尝试
    • 感知有没有成功获取锁
  • 总结synchronized和ReentrantLock的区别:

    1. synchronized是关键字,ReentrantLock是类
    2. ReentrantLock可以对获取锁的等待时间进行设置,避免死锁
    3. ReentrantLock可以获取各种锁信息
    4. ReentrantLock可以灵活的实现多路通知
    5. 机制:synchronized操作MarkWord,ReentrantLock调用Unsafe类的park()方法

9-4 jmm的内存可见性

  • 什么是Java内存模型中的happens-before?

  • Java 内存模型 JMM

    • Java内存模型(即Java Memory Model 简称JMM)是一种抽象概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式
      Java Memory Model
  • JMM中的主内存

    • 存储java实例对象
    • 包括成员变量、类信息、常量、静态变量
    • 属于数据共享区域,多线程并发操作时会引发线程安全问题
  • JMM中的工作内存

    • 存储当前方法的所有本地变量信息,本地变量对其他线程不可见
    • 字节码行号指示器、native方法信息
    • 属于线程私有的数据区域,不存在线程安全问题
  • JMM和java内存区域划分是不同的概念层次:

    • JMM描述的是一组规则,围绕原子性、有序性、可见性展开
    • 相似点:都存在共享区域和私有区域
    • 主内存和工作内存的数据存储类型以及操作方式归纳:
  • 方法里的基本数据类型本地变量将直接存储在工作内存的栈帧结构中

    • 引用类型的本地变量:引用存储在工作内存中,实例存储在主内存中
    • 成员变量、static变量、类信息均会被存储在主内存中
    • 主内存共享的方式线程各拷贝一份数据到工作内存,操作完成后刷新回主内存
  • JMM如何解决可见性问题?
    JMM如何解决可见性问题

  • 指令重排序需要满足的条件:

    • 单线程环境下不能改变程序运行的结果
    • 存在数据依赖关系的不允许重排序(无法通过happens-before原则推导出来的,才能进行指令重排序)
  • happens-before的八大原则:

    1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
    2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
    3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
    4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
    5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
    6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
    7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
    8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

9-5 CAS

  • 谈谈 CAS(Compare and Swap)?

    • 一种高效实现线程线程安全的方法

    • 支持原子更新操作,适用于计数器,序列发生器等场景

    • 属于乐观锁机制,号称lock-free(其实底层还是有加锁)

    • CAS操作失败时由开发者决定是否继续尝试,还是执行别的操作,所以失败线程不会阻塞挂起

  • CAS思想:

    • 包含三个操作数--内存位置V 预期原值A 和 新增B
    • 直接使用JUC的atomic包提供了常用的原子性数据类型以及引用、数组等相关原子类型和更新操作工具,是很多线程安全程序的首选
    • Unsafe类虽提供了CAS服务,但因为能够操纵任意内存地址的读写而有隐患
    • JDK9以后可以使用Variable Handle API来替代Unsafe
  • 缺点:

    • 若循环时间长,则开销很大
    • 只能保证一个共享变量的原子操作
    • ABA问题(解决方法:AtomicStampedReference,它可以控制变量的版本保证正确性)

9-6 Java线程池

Java线程池

  • 利用 Excutors 创建不同的线程池满足不同场景的需求

    • new FixedThreadPoll(int nThreads)
      //指定工作线程数量的线程池
      
    • new CachedThreadPool()
      //处理大量段时间工作任务的线程池  
      //1. 试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;
      //2. 如果线程闲置的时间超过阀值,则会被终止并移除缓存
      //3. 系统长时间闲置的时候,不会消耗什么资源 
      
    • new SingleThreadExecutor()
      //创建唯一的工作者线程来执行任务,如果线程异常结束,会有另一个线程取代它
      
    • new SingleThreadScheduledExecutor()
      new ScheduledThreasPool(int corePoolSize)
      //定期或者周期性的工作调度,两者的区别在于单一工作线程还是多个线程
      
    • new  WorkStealingPool()
      //内部会构建 ForkJoinPool,利用 working-stealing 算法,并行地处理任务,不保证处理顺序
      
  • Fork/Join 框架
    Fork/Join 框架

    • 把大任务分割成若干个小人物并行执行,最终汇总每个小任务结果后得到大任务结果的框架
    • work-stealing 算法:某个线程从启发队列你窃取任务来执行
  • 为什么要使用线程池

    • 降低资源消耗
    • 提高线程的可管理性
  • Executor的框架
    Executor的框架

  • JUC的三个Executor接口

    • Executor:运行新任务的简单接口,将人物提交和任务执行细节解耦
    • ExecutorService:具备管理执行器和任务生命周期方法,提交任务机制更完善
      1. ExecutorService是Executor的扩展接口 提供了更方便的管理方法 最常用的是 shutdown submit
      2. submit参数有Callable、Runnable两种 并返回Future
    • ScheduledExecutorService:支持Future和定期执行任务
  • ThreadPoolExecutor的构造函数

    • corePoolSize:核心线程数量
    • maximunPoolSize:线程不够用时能够创建的最大线程数
    • workQueue:任务等待队列(当前线程数量大于等于corePoolSize的时候,将任务封装成work放入workQueue中。不同的队列排队机制不同)
    • keepAliveTime:线程池维护线程的空闲时间,线程空闲超过这个时间就会被销毁
    • threadFactory:创建新线程,默认使用Executors.defaultThreadFactory(),新创建的线程是一样的优先级、非守护线程
  • 线程池执行流程图
    线程池执行流程图

  • handler:线程池的饱和策略

    • AbortPolicy:直接抛出异常,这是默认策略
    • CallerRunsPolicy: 用调用者所在多线程来执行任务
    • DiscardOldestPolicy:丢弃队列中最靠前的任务,并执行当前任务
    • DiscardPolicy:直接丢弃任务
    • 实现RejectedExecutionHandler接口自定义handler处理
  • 线程池的状态:

    • RUNNING:能够接受新任务,并且也能处理阻塞队列中的任务
    • SHUTDOWN:不能接受新任务,但可以处理存量任务
    • STOP:不再接受新任务,也不处理存量任务
    • TIDYING:所有任务都已终止,正在进行最后的打扫工作,有效线程数为0
    • TERMINATED:terminated()方法执行完成后进入该状态(该方法什么也不做只是标识)
      线程池状态装换图
  • 工作线程的生命周期
    工作线程的生命周期

  • 如何选择线程池大小?

    • CPU密集型:线程数=按照核数或者核数+1(因为如果线程太多会导致过多的上下文切换,导致不必要的开销)
    • I/O密集型:线程数量=CPU核数*(1+平均等待时间/平均工作时间)

阿里编码规范指出:线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors各个方法的弊端:

  • newFixedThreadPool和newSingleThreadExecutor:主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
  • newCachedThreadPool和newScheduledThreadPool:主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。
Comment