目录
面试: 什么是Java内存模型中的happens-before?
-
synchronized
-
存在线程安全问题的主要原因
- 存在共享数据
- 存在多个线程共同操作这些共享数据
- 解决办法
- 互斥访问共享数据
-
互斥锁的特性
- 互斥性
- 一个线程在操作临界资源时别的线程只能等待
- 互斥性也称为操作的原子性
- 可见性
- 必须确保在锁被释放之前, 对共享变量所做的修改, 对于随后获得该锁的线程是可见的, 否则另一个线程可能是在本地缓存的某个副本上继续操作, 造成数据的不一致性
- 互斥性
-
锁的分类
- 对象锁
- 类锁
-
-
面试: synchronized底层实现原理
- Java对象头和Monitor是实现synchronized的基础
- 对象头的结构
- Mark Word: 存储对象自身的运行时数据, 是实现轻量级锁和偏量锁的关键, 默认存储着对象的hashCode 分代年龄 锁类型 锁标志位
- Class Metadata Address: 类型指针, 指向对象的类元数据, jvm通过这个指针确定对象是哪个类的实例
- Monitor
- 每个Java对象都自带了一个隐藏的锁, 它叫做内部锁 or monitor锁
- 当使用同步代码块时, 在编译为字节码文件后读取到monitorenter指令就会尝试获取monitor锁, 读取到monitorexit时释放monitor锁
- 如果使用的是同步方法, 在字节码文件中使用ACC_SYNCHRONIZED标志位来声明方法是不是一个同步方法, 如果是一个同步方法的话, 那么线程在调用该方法的时候就会自动持有monitor锁
- 正是因为每个对象内部都有一个monitor锁, 才使得每个Java对象都可以成为锁对象
- 对象头的结构
-
面试: 什么是重入?
- 当一个线程通过同步方法获取到锁, 在线程运行的时候再次调用该同步方法获取锁就叫做重入, 而且是可以获取到锁的
-
面试: 为什么对synchronized锁嗤之以鼻?
- 早期的版本中, synchronized属于重量级锁, 依赖于底层操作系统的Mutex Lock实现
- 操作系统切换线程时需要从用户态转换到核心态, 开销较大, 导致早起的synchronized效率较低
- Java6之后, synchronized性能的到了很大提升, 引入了下面几种锁优化的技术
- 偏向锁
- 轻量级锁
- 锁消除
- 锁粗化
- 自适应自旋锁
-
偏向锁
- 减少同一线程获取锁的代价
- 大多数情况下, 锁不存在多线程竞争, 总是由同一线程多次获取
- 偏向锁的核心思想
- 如果一个线程获取到了锁, 那么锁就会进入偏向模式, 此时Mark Word的结构也会变成偏向锁结构, 当线程再次请求这个锁的时候, 就省去了大量有关锁申请的操作
- 即获取锁的时候只需要检查Mark Word的锁标记位是否为偏向锁, 以及当前线程的id是否等于Mark Word的ThreadID
- 不使用于锁竞争比较激烈的场合
-
轻量级锁
- 轻量级锁是偏量锁升级过来的, 当有不同的线程也来竞争这个锁的时候, 偏量锁就会升级为轻量级锁
- 是靠自适应自旋锁实现的
- 适用的场景: 多个线程交替执行某个同步块
-
自旋锁与自适应自旋锁
- 自旋锁
- 为了不让线程阻塞, 采用循环来获取锁的过程
- 许多情况下, 共享数据的锁定状态持续时间很短, 因为这么短的时间把其他线程阻塞挂起不值得, 不如让线程自己停在那里, 继续持有CPU资源(自旋), 因为有可能共享数据只锁定1s, 而把其他数据挂起就需要10s, 不值得.
- 如果锁被占用的时间非常短, 那么自旋锁的性能就比较好, 相反, 如果线程长时间持有锁就会带来更多的性能开销, 因为线程在自旋的时候会始终持有CPU资源
- 自适应自旋锁
- 因为锁被占用的时长是不固定的, 所以就引入了自适应自旋锁
- 自旋的次数不再固定
- 由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
- 假如上一个线程自旋结束后获取到了锁, 并且持有锁的线程正在运行中, 那么就认为该线程自旋后获取锁的机会比较大, 就会自动增加该线程的自旋次数
- 自旋锁
-
锁消除
- JIT编译时, 对运行上下文进行扫描, 去除不可能存在竞争的锁
-
锁粗化
- 另一种极端
- 通过扩大加锁的范围, 避免反复的加锁和解锁
-
synchronized的四种锁状态
- 无锁 偏向锁 轻量级锁 重量级锁
- 会随着竞争情况逐渐升级
- 锁膨胀方向: 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
- 四种锁状态特点 优缺点 适用场景汇总
-
锁的内存语义
- 当线程释放锁时, Java内存模型会把线程对应的本地内存中的共享变量刷新到主内存中
- 当线程获取锁时, Java内存模型会把该线程对应的本地内存置为无效, 从而使得被监视器保护的临界区代码必从主内存中读取共享变量
- 其实当线程释放锁时, 相当于线程把对临界区所做的修改发给下一个需要获取锁的线程, 线程获取到锁也相当于线程得到了上一个线程发来的修改信息
- 具体操作如下图
- Java对象头和Monitor是实现synchronized的基础
-
synchronized和ReentrantLock的区别
-
ReentrantLock
- 再入锁
- 位于juc包(java.util.concurrent)下
- 基于AQS实现
- AbstractQueuedSynchronizer, 队列同步器, 是Java用来构建锁的基础框架
- 调用lock()之后, 必须调用unlock()释放锁
-
区别如下
- synchronized是关键字, 是jvm实现的, ReentrantLock是类, 是JDK实现的
- ReentrantLock可以对获取锁的等待时间进行设置, 避免死锁
- 可以创建公平锁, synchronized是非公平锁
- 性能未必比synchronized高, 并且也是可重入的
- 在后续版本的改进中, synchronized在低竞争的场景下性能也很好
- 引入许多锁优化技术: 偏向锁 轻量级锁(使用自旋锁实现) 锁消除 锁粗化
- 在后续版本的改进中, synchronized在低竞争的场景下性能也很好
- ReentrantLock将锁对象化
- 带超时的获取锁
- 可以设置获取锁的时间, 超过这个时间就会退出获取锁
- 将wait/notify/notifyAll对象化
- 底层使用的是J.U.C包的ArrayBlockingQueue
- ArrayBlockingQueue是数组实现的 线程安全的 有界阻塞队列
- 线程安全: 内部使用ReentrantLock的公平锁保证线程安全
- 带超时的获取锁
-
ReentrantLock公平性设置
- 在ReentrantLock的构造方法中传入一个true参数就可以创建一个公平锁了
- 设置为true时, 会按照调用lock方法的先后顺序获取锁
- 设置为false时, 随机获取到锁
- 可以有效减少线程出现饥饿的情况
- 饥饿: 线程始终无法获取到锁
- 代码
-
package demo12_fairLock; import java.util.concurrent.locks.ReentrantLock; /** * @author LQ * @create 2020-07-28 12:32 */ public class Demo01 implements Runnable { /* true: 线程获取到锁的机会是相同的 false: 线程是随机获取到锁 */ private ReentrantLock lock = new ReentrantLock(true); // private static ReentrantLock lock = new ReentrantLock(false); @Override public void run() { while (true) { try { lock.lock(); System.out.println(Thread.currentThread().getName()); Thread.sleep(100); } catch (Exception e) { e.printStackTrace(); } finally { lock.unlock(); } } } public static void main(String[] args) throws InterruptedException { Demo01 task = new Demo01(); Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); } }
-
- Java默认的线程调度策略很少会导致饥饿的情况发生, 盲目的使用公平锁有可能产生额外的开销
-
-
面试: 什么是Java内存模型中的happens-before?
-
JMM
- Java内存模型(Java Memory Model)
- Java 内存模型试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果
- JMM定义了程序中各个变量的访问方式
- 每一个线程都有自己的工作内存, 存储着自己的私有数据. Java中所有的变量都存储在主内存中, 所有线程都可以访问. 线程想要操作这些变量, 需要把变量拷贝一个副本到自己的工作内存, 待操作完后再写回主内存中
-
JMM的主内存和工作内存
- 主内存
- 存储所有Java的实例对象
- 包括对象的成员变量 类信息 常量 静态变量
- 属于数据共享的区域, 多数据并发操作会引起线程安全问题
- 工作内存
- 工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝
- 存储当前方法的所有本地变量信息, 对其他线程不可见, 线程只能操作自己工作内存的变量
- 线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成
- 属于线程私有数据区域, 不存在线程安全问题
- 主内存
-
JMM的三大特性
- 原子性
- 原子性是指内存间的8个交互操作具有原子性
- 可见性
- 可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的
- 主要有三种方式实现可见性
- volatile
- synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
- final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。
- 有序性
- jvm会对Java指令重排序, 保证有序性是Java多线程的关键
- 详见另一篇笔记: https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20%E5%B9%B6%E5%8F%91.md#%E5%8D%81java-%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B
- 原子性
- 面试: JMM如何解决可见性问题?
-
指令重排
- 指令重排需要满足的条件
- 在单线程环境下不能改变程序运行的结果
- 存在数据依赖关系的不允许指令重排序
- 或者说: 不能使用happens-before原则推导出来的, 才能进行指令的重排序
- 指令重排需要满足的条件
-
happens-before
- 先于发生原则
- 上面提到了可以用 volatile 和 synchronized 来保证有序性。除此之外,JVM 还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成
- happens-before的八大原则
- 单一线程规则: 一个线程内, 按照代码顺序, 书写在前面的操作先行发生于写在后面的操作
- 先行发生的意思是, 前面操作的结果先行发生于后面操作的结果
- 管程锁定原则规则: 一个unlock操作先行发生于后面同一个锁的lock操作
- 意思是, 你加锁之前需要我先释放锁
- volatile变量规则: 对一个变量的写操作先行发生于后面对这个变量的读操作
- 这个规则确保了对volatile的修改对其他线程是立即可见的
- 传递规则: 如果操作A先行发生于操作B, 而操作B又先行发生于操作C, 则可以得出操作A先行发生于操作C
- 线程启动规则: Thread对象的start()方法先行发生于此线程的每一个操作
- 线程中断规则: 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件发生
- 线程终结规则: 线程中所有的操作都先行发生于线程的终止检测.
- 对象终结规则: 一个对象的初始化完成先行于他的finalize()方法开始
- 单一线程规则: 一个线程内, 按照代码顺序, 书写在前面的操作先行发生于写在后面的操作
- happens-before的概念
- 如果两个操作不满足上述八大规则的任意一个, 那么就说明这两个操作之间没有先后顺序之分, jvm是可以对这两个操作进行重排序的
- 如果操作A先行发生于操作B, 那么操作A对内存上所做的操作对操作B都是可见的
-
-
volatile
- 可挥发的 不稳定的 不确定的
- jvm轻量级同步机制
- 保证被volatile修饰的共享变量对所有线程总是立即可见的
- 当写一个volatile变量时, JMM会把线程对应的工作内存中的共享变量刷新到主内存中
- 当读一个volatile变量时, JMM会把自己的工作内存置为无效, 该线程只能从主内存中重新读取共享变量
- 禁止指令重排序
- 使用内存屏障禁止指令重排序
- 编译器或CPU执行指令时都可以做指令重排序, 通过插入内存屏障指令可以禁止内存屏障前后的指令执行指令重排序优化
- 面试: volatile和synchronized的区别
- volatile本质是告诉jvm当前变量的值是不确定的, 需要从主内存中重新获取; synchronized则是锁住当前变量, 只有当前线程可以访问此变量, 别的线程只能等待
- volatile仅能使用在变量级别; synchronized可以作用于变量 方法
- volatile标记的变量不会被编译器优化; synchronized标记的变量可以
- volatile禁止指令重排序
- synchronized保证只有一个线程可以访问共享数据, 其他线程必须等待, 在这种情况下, 对当前正在执行的线程指令进行重排序并不会对其他线程产生影响
-
面试: 单例的双重检测实现
- 实例代码
-
package demo13_volatile; public class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() {//第一次检测 if (instance == null) { //同步 synchronized (Singleton.class) { if (instance == null) { //多线程环境下可能会出现问题的地方 instance = new Singleton(); } } } return instance; } }
-
- 初始化对象时可能发生指令重排序
- 实例代码
-
CAS
- Compare and Swap
- 是一种非阻塞同步, synchronized和ReentrantLock也称为阻塞同步
- synchronized属于悲观锁, 悲观锁始终假定会发生并发冲突, 因此会屏蔽一切有可能违反数据完整性的操作; 乐观锁假定不会发生并发冲突, 只有在提交的时候才会去检查是否违反数据完整性, 如果提交失败则会进行重试
- 是一种高效实现线程安全的方法
- 支持原子更新操作, 适用于计数器, 序列发生器等场景
- 序列发生器: 用来给变量自增的工具
- CAS操作失败时由开发者决定是继续尝试, 还是执行别的操作, 因此更新失败的线程不会被阻塞挂起
- CAS思想
- CAS包含三个操作数: 内存位置(V) 预期原值(A) 新值(B)
- 提交更新操作时, 内存位置的旧值和A相等就更新, 否则就更新失败
-
AtomicInteger
- 原子型整数
- 详情请见另一篇笔记
- J.U.C的atomic包提供了常用的原子性数据类型 数组等相关原子类型和更新操作工具, 是很多线程安全程序的首选
- Unsafe虽然提供了CAS服务, 但因为它可以操作内存的任意位置而存在隐患
-
CAS缺点
- 若循环时间长, 则开销很大
- 在Unsafe类的getAndAddInt()方法中, 如果更新数据失败, 他的做法是不断的进行尝试
- ABA问题
- 如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过
- J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效
- 若循环时间长, 则开销很大
-
线程池
-
创建线程池可以使用Executors工具类提供的静态方法
- newFixedThreadPool(int nThreads)
- 创建指定工作线程数量的线程池
- newCachedThreadPool()
- 创建处理短时间工作任务的线程池
- 该线程池的特点是
- 当没有线程可用时, 可以创建新的工作线程
- 如果线程闲置的时间超过阈值(一般是60s), 则会被终止并移出缓存
- 所以使用这种线程池, 在系统长时间闲置时, 不会消耗什么资源
- newSingleThreadExecutor()
- 创建唯一的工作线程来执行任务, 如果线程异常结束, 会有另一个线程来取代它
- newSingleThreadScheduledExecutor()与newScheduledThreadPool(int corePoolSize)
- 定时或者周期性的工作调度, 二者的区别在于单一工作线程还是多个线程
- newWorkStealingPool()
- 内部会构建ForkJoinPool, 利用working-stealing算法, 并行处理任务, 不保证线程顺序
- newFixedThreadPool(int nThreads)
-
Fork/Join框架
- Java7提供
- 把大任务分割成若干个小任务并行执行, 最终汇总每个小任务结果后得到大任务结果的框架
- 已经完成任务的子线程可能需要长时间等待别的还没有完成任务的子线程, 这个时候, 使用working-stealing算法, 让完成任务的线程去窃取那些等待完成的子任务
-
面试: 为什么实现线程池?
- 降低资源消耗
- 频繁的创建销毁线程是很消耗资源的
- 提高线程的可管理性
- 降低资源消耗
- Executor的继承体系
- Executor: 运行新任务的简单接口, 其初衷是将任务提交和任务执行细节解耦
- 其内部只有一个execute()抽象方法
- ExecutorService: 扩展了Executor接口, 添加了一些管理执行器和任务生命周期的方法, 提交任务机制更加完善
- 提供了shutdown shutdownNow isShutdown等等方法
- 最重要的的submit方法
- ScheduledExecutorService: 扩展了ExecutorService, 支持Future和定期执行任务
-
线程池的内部实现
- ThreadPoolExecutor
- WorkQueue: 提交的新任务都是放在任务队列中, 然后在排队提交给线程池(工作线程的集合)
- ThreadFactory: 提供了创建线程池的逻辑
- RejectedExecutionHandler接口: 当提交给线程池的的任务被拒绝时, 可以自己实现这个接口, 编写被拒绝后需要执行的操作
- ThreadPoolExecutor的构造函数
- corePoolSize: 核心线程数量, 可以长期驻留的线程数
- maxmumPoolSize: 线程不够用时能够创建的最大线程数
- workQueue: 任务等待队列, 提交给线程池的任务如果不能及时处理会被放进任务等待队列中
- keepAliveTime: 如果线程池中的线程数量大于corePoolSize, 而此时又没有新的任务被提交, 核心外的线程不会被立即销毁, 而是会等待一段时间
- threadFactory: 创建新线程, 默认使用Executors.defaultThreadFactory()
- handler: 线程池的饱和策略, 提交给线程池的任务被拒绝时需要执行的操作
- 线程池一共提供了四种策略
- AbortPolicy: 直接抛出异常, 这是默认策略
- CallerRunsPolicy: 用调用者所在的线程来执行任务
- DiscardOldestPolicy: 丢弃任务中最靠前的任务, 并执行当前任务
- DiscardPolicy: 直接抛弃任务
- 也可以通过实现RejectedExecutionHandler接口来自定义处理方法
- 新任务提交execute执行后的判断
- 如果线程池中的线程数小于corePoolSize, 则创建新线程来处理任务
- 如果线程池中的线程数量>=corePoolSize 并且 <=maximumPoolSize, 则只有workQueue队列满时才会创建新的线程处理任务
- 虽然我还可以创建新的线程, 但只有迫不得已的时候我才会这么做
- 如果设置的corePoolSize和maximumPoolSize相同, 则创建的线程池的大小是固定的, 这时如果有新的任务提交, 如果workQueue还没有满, 则放进去, 等待有线程闲置时去处理, 否则交给handler指定的策略去处理
- 流程图
- ThreadPoolExecutor
-
线程池的状态
- RUNNING: 能够接受新提交的任务, 能够处理workQueue中的任务
- SHUTDOWN: 不再接受新提交的任务, 但仍可以处理线程池中剩余的任务
- 调用shutdown()方法进入这个状态
- STOP: 不再接受新提交的任务, 也不再处理线程池中剩余的任务
- 调用shutdownNow()方法会使线程池进入这个状态
- TIDYING: 所有任务都已经终止了
- 调用terminated()方法进入最后一个状态
- TERMINATED
- 状态转换图
-
面试: 如何确定线程池的大小?
- 确定线程池的大小并没有一个万能的计算方法
- CPU密集型: 线程数 = 核数 or 核数+1
- I/O密集型: 线程数 = CPU核数 * (1 + 平均等待时间/平均工作时间)
-