剑指Java面试-Offer直通车3

第8章 Java多线程与并发

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

2:

hotspot中对象在内存的布局是分3部分 

  1. 对象头
  2. 实例数据
  3. 对其填充

这里主要讲对象头:一般而言synchronized使用的锁对象是存储在对象头里的,对象头是由Mark Word和Class Metadata Address组成

 

mark word存储自身运行时数据,是实现轻量级锁和偏向锁的关键,默认存储对象的hasCode、分代年龄、锁类型、锁标志位等信息。

由于对象头的信息是与对象定义的数据没有关系的额外存储成本,所以考虑到jvm的空间效率,mark word 被设计出一个非固定的存储结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间(轻量级锁和偏向锁是java6后对synchronized优化后新增加的)

Monitor:每个Java对象天生就自带了一把看不见的锁,它叫内部锁或者Monitor锁(监视器锁)。上图的重量级锁的指针指向的就是Monitor的起始地址。

每个对象都存在一个Monitor与之关联,对象与其Monitor之间的关系存在多种实现方式,如Monitor可以和对象一起创建销毁、或当线程获取对象锁时自动生成,当线程获取锁时Monitor处于锁定状态。

Monitor是虚拟机源码里面用C++实现的

源码解读:_WaitSet 和_EntryList就是之前学的等待池和锁池,_owner是指向持有Monitor对象的线程。当多个线程访问同一个对象的同步代码的时候,首先会进入到_EntryList集合里面,当线程获取到对象Monitor后就会进入到_object区域并把_owner设置成当前线程,同时Monitor里面的_count会加一。当调用wait方法会释放当前对象的Monitor,_owner恢复成null,_count减一,同时该线程实例进入_WaitSet集合中等待唤醒。如果当前线程执行完毕也会释放Monitor锁并复位对应变量的值。

接下来是字节码的分析:

package interview.thread;

/**
 * 字节码分析synchronized
 * @Author: cctv
 * @Date: 2019/5/20 13:50
 */
public class SyncBlockAndMethod {
    public void syncsTask() {
        synchronized (this) {
            System.out.println("Hello");
        }
    }

    public synchronized void syncTask() {
        System.out.println("Hello Again");
    }
}

然后控制台输入 javac thread/SyncBlockAndMethod.java

然后反编译 javap -verbose thread/SyncBlockAndMethod.class

先看看syncsTask方法里的同步代码块

从字节码中可以看出 同步代码块 使用的是 monitorenter 和 monitorexit ,当执行monitorenter指令时当前线程讲试图获取对象的锁,当Monitor的count 为0时将获的monitor,并将count设置为1表示取锁成功。如果当前线程之前有这个monitor的持有权它可以重入这个Monnitor。monitorexit指令会释放monitor锁并将计数器设为0。为了保证正常执行monitorenter 和 monitorexit 编译器会自动生成一个异常处理器,该处理器可以处理所有异常。主要保证异常结束时monitorexit(字节码中多了个monitorexit指令的目的)释放monitor锁

ps:重入是从互斥锁的设计上来说的,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入。就像如下情况:hello2也是会输出的,并不会锁住。

再看看syncTask同步方法

解读:这个字节码中没有monitorenter和monitorexit指令并且字节码也比较短,其实方法级的同步是隐式实现的(无需字节码来控制)ACC_SYNCHRONIZED是用来区分一个方法是否同步方法,如果设置了ACC_SYNCHRONIZED执行线程将持有monitor,然后执行方法,无论方法是否正常完成都会释放调monitor,在方法执行期间,其他线程都无法在获得这个monitor。如果同步方法在执行期间抛出异常而且在方法内部无法处理此异常,那么这个monitor将会在异常抛到方法之外时自动释放。

 

java6之前Synchronized效率低下的原因:

在早期版本Synchronized属于重量级锁,性能低下,因为监视器锁(monitor)是依赖于底层操作系统的的MutexLock实现的。

而操作系统切换线程时需要从用户态转换到核心态,时间较长,开销较大

java6以后Synchronized性能得到了很大提升(hotspot从jvm层面做了较大优化,减少重量级锁的使用):

  1. Adaptive Spinning 自适应自旋
  2. Lock Eliminate 锁消除
  3. Lock Coarsening 锁粗化
  4. Lightweight Locking 轻量级锁
  5. Biased Locking偏向锁
  6. ……

自旋锁:

  • 许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得
  • 通过让线程执行while循环等待锁的释放,不让出CPU
  • java4就引入了,不过默认是关闭的,java6后默认开启的
  • 自旋本质和阻塞状态并不相同,如果锁占用时间非常短,那自旋锁性能会很好
  • 缺点:若锁被其他线程长时间占用,会带来许多性能上的开销,因为自旋一直会占用CPU资源且白白消耗掉CPU资源。
  • 如果线程超过了限定次数还没有获取到锁,就该使用传统方式挂起线程(可以设置VM的PreBlockSpin参数来更改限定次数)

 

 

自适应自旋锁:(java6引入,jvm对锁的预测会越来越精准,jvm也会越来越聪明)

  1. 自选次数不再固定
  2. 由前一次在同一个锁上的自旋时间及锁拥有者的状态来决定(如果在同一个锁对象上自旋等待刚刚成功获取过锁并且持有锁的线程正在运行中,jvm会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间,相反jvm 如果可能性很小会省掉自旋过程,避免浪费)

锁消除:jvm的另一种锁优化,更彻底的优化

  • JIT编译时,对运行上下文进行扫描,去除不可能存在的竞争的锁,消除毫无意义的锁

锁粗化:另一种极端,锁消除的作用在尽量小的范围使用锁,而锁粗化则相反,扩大加锁范围。比如加锁出现在循环体中,每次循环都要执行加锁解锁的,如此频繁操作比较消耗性能

  • 扩大加锁范围,避免反复的加锁和解锁

synchronized的四种状态

  1. 无锁
  2. 偏向锁
  3. 轻量级锁
  4. 重量级锁

锁膨胀方向:无锁->偏向锁->轻量级锁->重量级锁,synchronized会随着竞争情况逐渐升级,如出现了闲置的monitor也会出现锁降级

偏向锁:减少同一个线程获取锁的代价

  1. 大多数情况下,锁不存在多线程竞争,总是由同一个线程多次获得

ps:核心思想就是如果一个线程获得了锁,那么锁就进入偏向模式,此时MarkWord的结构也变成偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查MarkWord的锁标记位为偏向锁以及当前线程ID等于MarkWord的ThreadID即可,这样就省去了大量有关锁申请的操作

不适合用于锁竞争比较激烈的多线程场合

轻量级锁:

轻量级锁是由偏向锁升级而来的,偏向锁运行再一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁

适用场景:线程交替执行的同步块

若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁

轻量级锁的加锁过程:

此图来自https://blog.csdn.net/zqz_zqz

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间(线程私有的栈帧里),用于存储锁对象目前的Mark Word的拷贝(对象是存在堆中的,所以对象的MarkWord也再堆中),官方称之为 Displaced Mark Word。
  2. 拷贝对象头中的Mark Word复制到锁记录中;
  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示。
  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。 

锁的内存语义

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

总结:

 

问:synchronized和ReentrantLock的区别?

ReentrantLock(可重入锁)

  • 位于java.util.concurrent.locks包(著名的juc包是由Doug lea大神写的AQS抽象类框架衍生出来的应用)
  • 和CountDownLatch、FutureTask、Semaphore一样基于AQS实现
  • 能够实现比synchronized更细粒度的控制,如控制fairness
  • 调用lock()后,必须调用unlock()释放锁
  • 性能未必比synchronized高,并且也是可重入的

ReentrantLock公平性设置

ReentrantLock fairLock = new ReentrantLock(true);

参数为ture时,倾向于将锁赋予等待时间最久的线程

公平锁:获取锁的顺序按先后调用lock方法的顺序(慎用,通常公平性没有想象的那么重要,java默认的调用策略很少会有饥饿情况的发生,与此同时若要保证公平性,会增加额外的开销,导致一定的吞吐量下降)

非公平锁:获取锁的顺序是无序的,synchronized是非公平锁

例子:

package interview.thread;

import java.util.concurrent.locks.ReentrantLock;

/**
 * @Author: cctv
 * @Date: 2019/5/21 11:46
 */
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 (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }

    }

    public static void main(String[] args) {
        ReentrantLockDemo rtld = new ReentrantLockDemo();
        Thread t1 = new Thread(rtld);
        Thread t2 = new Thread(rtld);
        t1.start();
        t2.start();
    }
}

公平锁 new ReentrantLock(true);

非公平锁 new ReentrantLock(false);

ReentrantLock将锁对象化

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

是否能将wait\notify\notifyAll对象化

  • java.util.concurrent.locks.Condition

总结synchronized和ReentrantLock的区别:

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

 

 

volatile和synchronized的区别

  • volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  • volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
  • volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
  • volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  • volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

3:CAS(Co'mpare  and Swap)

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

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

2、属于乐观锁机制,号称 lock - free

3、CAS操作失败时由开发者决定是继续尝试,还是执行别的操作。

 

悲观锁:

 CAS 多数情况下对开发者来说是透明的。

 

 

在使用CAS 前要考虑ABA 问题 是否影响程序并发的正确性,如果需要解决ABA 问题,改用传统的互斥同步,可能会比原子性更高效。

 

java线程池,利用Exceutors创建不同的线程池满足不同场景需求:

  1. newSingleThreadExecutor() 创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
  2. newFixedThreadPool(int nThreads) 创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
  3.  newCachedThreadPool() 创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程, 那么就会回收部分空闲(默认60秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小
    1. 优点就是灵活创建线程,因地制宜,任务少时很省资源。缺点就是可创建的线程上限太大,源代码里是Integer.MAX_VALUE大小,这个数量有点可怕
  4. newScheduledThreadPool() 创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。
  5. newWorkStealingPool() jdk8引入的,内部会构建ForkJoinPool,利用working-stealing算法,并行的处理任务,但是不保证处理顺序

Fork/Join框架

  • 把大任务分割成若干个小任务并行执行,最终汇总每个小任务结果后得到大任务的框架
  • work-stealing算法:某个线程从其他线程队列里窃取任务来执行

因为分割成若干个小任务由多个线程去执行,就会出现有的线程已经完成任务而有的还未完成任务,已经完成的线程就闲置了,为了提升效率,让已经完成任务的线程去其他线程窃取队列里的任务来执行。为了减少窃取线程对其他线程的竞争,通常会使用双端队列,执行任务的线程从头部拿任务执行,窃取线程是从队列尾部拿任务执行

 

问:为什么要使用线程池?

  1. 降低资源消耗(通过重复利用已创建的线程来工作,降低创建线程和销毁线程的消耗)
  2. 提高线程的可管理性(线程是稀缺资源,如果无限制的创建会不仅会消耗系统资源还会降低系统的稳定性,使用线程池可以统一的分配、调优、监控)

 

Executor的框架图

JUC的三个Executor接口

  1. Executor:运行新任务的简单接口,将人物提交和任务执行细节解耦
    1. 通过源码看到newCachedThreadPool是返回的ExecutorService newSingleTreadExecutor是返回的FinalizableDelegatedExecutorService  最终都是继承的Executor
    2. 接口Executor只有一个方法就是execute,对于不同的实现它可能是创建一个新线程立即启动,也可能是使用已有的线程来运行传入的任务,也可能是根据线程池容量或阻塞队列的容量来决定是否将传入的任务放入阻塞队列中或者拒绝接受任务
  2. ExecutorService:具备管理执行器和任务生命周期方法,提交任务机制更完善
    1. ExecutorService是Executor的扩展接口 提供了更方便的管理方法 最常用的是 shutdown submit
    2. submit参数有Callable、Runnable两种 并返回Future
  3. ScheduledExecutorService:支持Future和定期执行任务

ThreadPoolExecutor的构造函数

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

ps:newCachedThreadPool传入的队列是容量为0的SynchronousQueue,(Java 6的并发编程包中的SynchronousQueue是一个没有数据缓冲的BlockingQueue,生产者线程对其的插入操作put必须等待消费者的移除操作take,反过来也一样)

handler:线程池的饱和策略

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

execute方法执行流程如下:

线程池的状态:

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

状态转换图:

工作线程的生命周期:

问:如何选择线程池大小?(没有绝对的算法或规定,是靠经验累计总结出来的)

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

    阿里编码规范指出:线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors各个方法的弊端:
    1)newFixedThreadPool和newSingleThreadExecutor:
      主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
    2)newCachedThreadPool和newScheduledThreadPool:
      主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。

    例子:使用Guava的ThreadFactoryBuilder 

    输出:

    不加重试的输出是:

    从例子中看出 maxPoolSize + QueueSize < taskNum 就会抛出拒绝异常 如果不catch这个异常程序无法结束(这里重试机制只是个demo,正确的做法是实现RejectedExecutionHandler接口自定义handler处理)

posted @ 2019-07-03 14:44  zhangniuniu  阅读(185)  评论(0编辑  收藏  举报