Java多线程与并发相关 — 原理
一 synchronized同步
1. 线程安全问题的主要诱因?
- 存在共享资源(也称临界资源);
- 存在多条线程共同操作这些共享数据;
2. 解决办法.
同一时刻有且只能有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作;
3. 互斥锁的特性
- 互斥性 : 在同一时刻只允许一个线程持有某个对象的锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块进行访问.互斥性也称为操作的原子性;
- 可见性 : 在获得锁以后必须获得最新的共享变量的值,否则可能导致数据不一致;
- synchronized锁的不是代码而是对象;
4. synchronized锁的分类
根据获取的锁的分类:获取对象锁和获取类锁
- 获取对象锁的两种用法
- 同步代码块(synchronized(this),synchronized(类实例对象)),锁是小括号()中的实例对象。
- 同步非静态方法(synchronized method),锁是当前对象的实例对象
- 获取类锁的两种方法
- 同步代码块(synchronized(类.class)),锁是小括号()中的类对象(Class对象)。
- 同步静态方法(synchronized static method),锁是当前对象的类对象(Class对象)
5. 类锁与对象锁的总结.
- 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块;
- 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块的线程会被阻塞;
- 若锁住的是同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程会被阻塞;
- 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象同步方法的线程会被阻塞,反之亦然;
- 同一类的不同对象的对象锁互不干扰;
- 类锁由于也是一种特殊的对象锁,因此表现和上述1, 2, 3, 4一致,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁将会是同步的;
- 类锁和对象锁互不干扰;
二 synchronized底层实现原理
1. 实现synchronized的基础
- Java对象头;
- Moniter;
2. 对象在内存中的布局
- 对象头;
- 实例数据;
- 对齐填充;
3. 对象头的结构
4. Mark Word
5. Monitor : 每个对象天生带了一把看不见的锁
6. 什么是重入?
从互斥锁的设计上来说,当一个线程视图操作一个由其他线程持有的对象锁的临界资源,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入。
7.为什么会对synchronized嗤之以鼻?
- 早期版本中,synchronized属于重量级锁,依赖于Mutex Lock(互斥锁)实现
- 线程之间的切换需要从用户态转换到核心态,开销较大
- 然而,java6之后,synchronized性能得到了很大的提升
8. 自旋锁
- 许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得;
- 通过让现场执行忙循环等待锁的释放,不让出CPU;
- 缺点:若锁被其他线程长时间占用,会带来许多性能上的开销;
9. 自适应自旋锁
- 自旋的次数不在固定;
- 由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定;
10. 锁消除
- 这是一个更彻底的优化, JTI编译时对运行上下文进行扫描,去除不可能存在竞争的锁;
11. 锁粗化
- 另一种极端:通过扩大加锁的返回,避免返回加锁和解锁
12. synchronized的四种状态
- 无锁、偏向锁、轻量级锁、重量级锁
- 锁膨胀方向:无锁 -->偏向锁–>轻量级锁–>重量级锁
13. 偏向锁
偏向锁:减少统一线程获取锁的代价 CAS(Compare And Swap)
- 大多数情况下,锁不存在多线程竞争,纵使由同一线程多次获得
核心思想:
- 如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当该线程再次请求锁时,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。
- 不适用与锁竞争比较激烈的多线程场合
14. 轻量锁
- 轻量级锁是有偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。
- 适用的场景:线程交替执行同步块
- 若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁
15. 锁的内存语义
- 当线程释放锁时,Java内存模型(JMM)会把该线程对应的本地内存中的共享变量刷新到主内存中。
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。
16. 偏向锁、轻量级锁、重量级锁的汇总
三 synchronized和ReentrantLock的区别
1. ReentrantLock(再入锁)
- 位于java.util.concurent.locks包
- 和CountDownLatch、FutureTask、Semaphore一样基于AQS实现
- 能够实现比synchronized更细粒度的控制,如控制fairness
- 调用lock()之后,必须调用unlock()释放锁
- 性能未必比synchronized高,并且也是可重入的。
2. ReentrantLock公平性的设置
- ReentrantLock fairLock = new ReentrantLock(true);
- -before参数为true时,倾向于将锁赋予等待时间最久的线程
- 公平锁:获取所得顺序按先后调用lock方法的顺序(慎用)
- 非公平锁:抢占的顺序不一定,看运气
- synchronized是非公平锁
3. ReentrantLock将锁对象化
- 判断是否有线程,或者某个特定线程,在排队等待获取锁
- 带超市的获取锁的尝试
- 感知有没有成功获取锁
4. 是否能将wait\notify\notifyAll对象化?
- java.util.concurrent.locks.Condition
5. synchronized和ReentrantLock的区别?
- synchronized是关键字,ReentrantLock是类
- ReentrantLock可以对获取所得等待时间进行设置,避免死锁
- ReentrantLock可以获取各种锁的信息
- ReentrantLock可以灵活地实现多路通知
- 机制:sync操作Mark Word,lock调用Unsafe类的park()方法>
四 什么是Java内存模型的happens-before?
1. Java 内存模型JMM
- Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
2. JMM的主内存
- 存储Java实例对象
- 包括成员变量、类信息、常量、静态变量等
- 属于数据共享的区域,多线程并发操作时会引发线程安全问题
3. JMM的工作内存
- 存储当前方法的所有本地变量信息,本地变量对其他线程不可见
- 字节码行号指示器、Native方法信息
- 属于线程私有数据区域,不存在线程安全问题
4. JMM与Java内存区域划分是不同的概念层次
- JMM描述的是一组规则,围绕原子性,有序性、可见性展开
- 相似点:存在共享区域和私有区域
5. 主内存与工作内存的数据存储类型以及操作方式归纳
- 方法里的基本数据类型本地变量将直接存储在工作内存的栈帧结构中
- 引用类型的本地变量:引用存储在工作内存中,实例存储在主内存中
- 成员变量、static变量、类信息均会被存储在主内存中
- 主内存共享的方式是线程各自拷贝一份数据到工作内存,操作完成后刷新回主内存
6. JMM如何解决可见性问题
- 指令重排序需要满足的条件
- 在单线程环境下不能改变程序运行的结果
- 存在数据依赖关系的不允许重排序
- 无法通过happens-before原则推导出来的,才能进行指令的重排序
- A操作的结果需要对B操作可见,则A与B存在happens-before关系
i = 1;//线程A执行
j = i;//线程B执行
7. happens-before的八大原则
happens-before的概念:
- 如果两个操作不满足以下任意一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序;
- 如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。
- 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
- 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
- volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
- 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
- 传递性 A先于B ,B先于C 那么A必然先于C
- 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
- 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
- 对象终结规则 对象的构造函数执行,结束先于finalize()方法
8. voltage变量为何立即可见?
- 当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中;
- 当读取一个vol;atile变量时,JMM会把该线程对应的工作内存置为无效。
9. volatile如何禁止重排序优化
- 内存屏障(Memory Barrier)
- 保证特定操作的执行顺序
- 保证某些变量的内存可见性
- 通过插入内存屏障指令禁止在内存屏障前后的指令执行重排序优化
- 强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本
10. volatile和synchronized的区别
- volatile本质是在告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取,synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住.
- volatile仅能使用在变量级别,synchronized则可以使用在变量,方法.
- volatile仅能实现变量的修改可见性,不能保证原子性,而synchronized则可以保证变量的修改可见性和原子性.
- volatile不会造成线程的阻塞,而synchronized可能会造成线程的阻塞.
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
- 当一个域的值依赖于它之前的值时,volatile就无法工作了,如n=n+1,n++等。如果某个域的值受到其他域的值的限制,那么volatile也无法工作,如Range类的lower和upper边界,必须遵循lower<=upper的限制。
- 使用volatile而不是synchronized的唯一安全的情况是类中只有一个可变的域。
四 CAS(Compare and Swap)
0. 前言
- JUC是java.util.concurrent包的简称,JUC有2大核心,CAS和AQS,CAS是java.util.concurrent.atomic包的基础,即AtomicInteger和AtomicLong等是用CAS实现的
1. 一种高效实现线程安全性的方法
- 支持原子更新操作,适用于计数器,序列发生器等场景
- 属于乐观锁机制,号称lock-free
- CAS操作失败时由开发者决定是继续尝试,还是执行别的操作
2. CAS思想
- 包含三个操作数——内存位置(V)、预期原值(A)和新值(B)
CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
3. CAS多数情况下对开发者来说是透明的
- J.U.C的atomic包提供了常用的原子性数据类型以及引用、数组等相关原子类型和更新操作工具,是很多线程安全程序的首选
- Unsafe类虽提供了CAS服务,但因能够操纵任意内存地址读写而有隐患
- Java9以后,可以使用Variable Handle API来代替Unsafe
4. CAS缺点
只能保证对一个变量的原子性操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。
长时间自旋会给CPU带来压力
我们可以看到getAndAddInt方法执行时,如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。
ABA问题
如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那我们就能说它的值没有被其他线程改变过了吗?
如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。Java并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
五 Java线程池
1. 为什么要使用线程池
- 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
2. 利用Executors创建不同的线程池满足不同场景的需求
- newCachedThreadPool()
创建一个可缓存线程池,处理大量短时间工作任务的线程池 - 试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;
- 如果线程闲置的时间超过阈值,则会被终止并移除缓存;
- 系统长时间闲置的时候,不会消耗生命资源
- newFixedThreadPool(int nThreads)
创建一个定长线程池,可控制线程最大并发数 - newSingleThreadExecutor
创建唯一的单线程来执行任务,如果线程异常结束,会有另一个线程取代它 - newSingleScheduledExecutor()与newScheduledThreadPool(int corePoolSize)
定时或者周期性的工作调度,两者的区别在于单一工作线程还是多个线程 - newWordStealingPool()
内部会构建ForkJoinPool,利用working-stealing算法,并行地处理任务,不保证处理顺序
2. Fork/Join框架
- 把大任务分割成若干个小任务并行执行,最终汇总每个小任务结果后得到大人物结果的框架
- Work-Stealing算法:某个线程从其他队列里窃取任务来执行(使用双端队列)
3. J.U.C的三个Executor接口
- Executor:运行新任务的简单接口,将任务提交和任务执行细节解耦
- ExecutorService:具备管理执行器和任务生命周期的方法,提交任务机制更完善
- ScheduledExecutorService:支持Future和定期执行任务
4. ThreadPoolExecutor
1. ThreadPoolExecutor的构造函数
- corePoolSize:核心线程数量
- maxmumPoolSize:线程不够用时能够创建的最大线程数
- workQueue:任务等待队列
- keepAliveTime:抢占的顺序不一定,看运气
- threadFactory:创建新线程,Executors.defaultThreadFactory()
- handle:线程池的饱和策略
- AbortPolicy:直接抛出异常,这是默认策略
- CallRunsPolicy:用调用者所在的线程来执行任务
- DiscardOldestPolicy:丢弃队列中靠前的任务,并执行当前任务
- 实现RejectedExecutionHandler接口的自定义handler
2. 新任务提交execute执行后的判断
- 如果运行的线程小于corePoolSize,则创建新线程来处理任务,即使线程池中的线程是空闲的;
- 如果线程池中的线程数量大于等于corePoolSize且小于maxmumPoolSize,则只有当workQueue满时才创建新的线程去处理任务;
- 如果设置的corePoolSize和maximumPoolSize相同,则创建的线程池的大小是固定的,这时如果有新任务提交,若workQueue未满,则将请求放入workQueue中,等待有空闲的线程去从workQueue中取任务并处理
- 如果运行的线程数量大于等于maximumPoolSize,这时如果workQueue已经满了,则通过handler所指定的策略来处理任务;
5. 线程池的状态
- RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务。线程池的初始化状态是RUNNING。线程池被一旦被创建,就处于RUNNING状态,并且线程池中的任务数为0。
- SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。调用线程池的shutdown()方法时,线程池由RUNNING -> SHUTDOWN。
- STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。调用线程池的shutdownNow()方法时,线程池由(RUNNING or SHUTDOWN ) -> STOP。
- TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为 TIDYING 状态时,会执行构造方法 terminated()。因为**terminated()**在ThreadPoolExecutor类中是空的,所以用户想在线程池变为TIDYING时进行相应的处理;可以通过重载terminated()函数来实现。
当线程池在SHUTDOWN状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。
当线程池在STOP状态下,线程池中执行的任务为空时,就会由STOP -> TIDYING。 - TERMINATED:线程池处在TIDYING状态时,执行完**terminated()**之后,就由 TIDYING -> TERMINATED。
6. 工作线程的生命周期
7. 线程池的大小如何选定
- CPU密集型:线程数= CPU核心数量+1设定
- I/O密集型:线程数 = CPU核心数量 * (1 + 平均等待时间/平均工作时间)