1、并发 BUG 的原因
可见性
一个线程对变量的改变,另一个线程能立刻看到。
CPU缓存导致的可见性问题。(volitale 关键字修饰,可以使修饰的变量值在当前线程修改后,对其他线程立刻可见)
原子性
一个或者多个操作在 CPU 执行的过场中不被中断的特效叫原子性。
CPU 能保证的原子操作是 CPU 指令级别的,而我们需要在高级语言层面保证操作的原子性。
线程切换导致的原子性问题。
编译优化
双重检查创建单例对象,可能会触发空指针异常。(new 操作点的顺序的优化)
编译优化带来的有序性问题。
写并发程序的时候,知道并发程序的问题在哪里,然后就有方向去针对解决。
2、解决有序性和可见性
内存模型:规范了 JVM 如何提供按需禁用缓存和编译优化。
导致可见性、有序性的原因是缓存和编译优化,那就按需禁用缓存及编译优化。
包括了 volatile、synchornized、final 三个关键字,以及 Happens-Before 规则。
volatile 强制让其他线程读去内存,而不是 CPU 缓存。
Happens-Before 规则
前一个操作对后续操作是可见的。约束了编译器优化后一定遵守 Happens-Before原则。(无论 A 事件和 B 事件是否发生在同一个线程里)
3、互斥锁
原子性的源头是线程切换。
32位机器 多核情况下,可能两个线程(执行在2个 CPU 上)在同时执行写 long 型变量高 32 位。线程中断只能让 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行。(32位执行 Long 变量写操作可能会出现 bug 的原因)
同一时刻只能有一个线程执行,叫互斥。就能保证对共享变量的修改是互斥的,保证原子性。
把一段需要互斥执行的代码成为 临界区。(锁代表:synchronized)
synchornized 修饰静态方法时,锁住的是当前类的 Class 对象。修饰非静态方法时,锁住的是当前实例对象 this。
sync 修饰三种方法的不同点,在字节码层面,锁代码块的时候是用对象头的 Monitor ,锁方法和静态方法是放在堆和常量池中,用标志位。但是最终的 JVM 底层,都是用的对象头来锁住。
加锁的本质,是在将被锁的对象的对象头,写入当前线程 id。所以锁住非静态方法时,锁住的是对象。如果每次都是 new 一个新对象出来,没有线程 id,任意线程都可以操作该对象,那么就没有锁住对象。
可以用一把锁锁住多个资源,不能用多把锁锁住一个资源。
用不同的锁对受保护资源进行精细化管理,能够提升性能。叫细粒度锁。
如果资源之间有关系,选择一个更大粒度的锁,例如锁住类对象。
4、死锁
使用细粒度锁,多个锁,虽然是优化的重要手段,但是也可能会导致死锁。
死锁一般情况只能重启应用。所以最开始就要规避死锁。
死锁的产生的破坏方法,看课程。
5、管程(Monitor):并发编程的万能钥匙
管程:管理共享变量以及对共享变量的操作过程,让要他们支持并发。
并发的两大核心:互斥(同一时刻只允许一个线程访问共享变量)、同步(线程之间如何通信、协作)。
6、Java 线程的生命周期
初始:编程语言层面线程被创建,操作系统层面还未创建线程。
可运行/运行。
休眠(阻塞、无时限等待、有时限等待)。
终止:执行完或者出现异常。
join(),线程同步方法,主线程中调用 A.join(),主线程执行这条语句的线程会等待 thread A 执行完。该线程状态就从 运行转换到 无时限等待 。
当线程只是 new 的时候,线程处于可运行状态。要 线程.start() 后,线程才处于 运行 状态。
stop() 是杀死线程,如果线程持有 sync 隐式锁,也不会释放锁,其他线程就没有机会获取到 sync 隐式锁了。
interrupt() 方法通知线程停止,线程可以做一些后续操作或者无视这个通知。
当线程 A 处于 无限时等待/限时等待,如果其他线程调用 A 线程,则线程 A 会回到 运行状态,并且出发 InterruptedException 异常。(触发条件,其他线程调用了该线程的 interrupt() 方法)
7、创建多少 Java 线程是合适的
使用多线程就是为了提升应用性能。
性能量化核心:延迟和吞吐量。
提升硬件的利用率,就是提升 I/O 的利用率的 CPU 的利用率。多线程能提升 I/O 和 CPU 的综合利用率。
程序一般是 CPU 计算和 I/O操作交替进行。有 I/O 密集型计算场景和 CPU 密集型计算场景。
对于 CPU 密集型计算场景,理论 最佳线程数 = CPU 核数,一般是 CPU 核数 + 1。
I/O 密集型场景 最佳线程数 = 【1 + (I/O 耗时 / CPU 耗时)】 * 核数。
8、局部变量是线程安全的
成员变量属于对象的字段,则放入堆中。
局部变量属于方法的字段,则放入栈中。所以不存在线程不安全的说法。
堆和栈,就是让跨越方法的变量放堆里,不共享的对象放栈里。
9、面向对象思想写好并发程序
封装共享变量
将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。外界对象只能通过公共方法来间接访问这些内部属性。
对于不会发生变化的共享变量,用 final 关键字修饰。
识别共享概念股变量间的约束条件
AtomicLong 原子类,线程安全。
但是要防止约束条件不够的情况,一般出现 if 条件的时候就会出现竞态条件。
制定并发访问策略
避免共享:线程本地存储以及为每个任务分配独立的线程。
管程及其他同步工具
优先使用成熟的工具类、迫不得已才使用低级同步原语、避免过早优化
10、Lock & Condition
并发包中的管程。
Lock 解决互斥,Condition 解决同步问题。
破坏不可抢占条件
1)、能够响应中断。 sync 的问题是线程持有锁 A,尝试获取锁 B 失败,则进入阻塞。一旦发生死锁,则没有机会唤起阻塞的线程。但是如果阻塞状态的线程可以响应中断信号,可以唤醒该线程,就有机会释放曾经持有的锁 A。就能破坏不可抢占条件。
2)、支持超时。线程在一段时间内没有获取到锁,不是进入阻塞,而是返回一个错误。则该线程也有机会释放曾经持有的锁。
3)、非阻塞地获取锁。尝试获取锁失败,不进入阻塞。则该线程也有机会释放曾经持有的锁。
这三个方面能全面弥补 sync 的问题。所以 Lock 接口的三个方法对应了这三个方案。
如何保证可见性
sync 的解锁 Happens-Before 于后续对这个锁的加锁。
Lock 也是利用了 volatile 相关的 Happens-Before 规则。ReentrantLock 内部持有一个 volatile 的成员变量 state。获取锁会读写 state 的值。 相关的 Happens-Before 规则
1)、顺序性规则
2)、volatile 变量规则
3)、传递性规则
可重入锁
可重复获取的同一把锁。同一个线程的方法中,对一个锁对象多次加锁。
如果锁不可重入,那么线程就会被阻塞。
公平锁和非公平锁
ReentrantLock() 默认非公平锁,有参构造传入 true 代表构造一个公平锁。
锁都会对应着一个等待队列。如果一个线程没有获取到锁,就会进入等待队列。所以公平、非公平锁,就是当线程释放锁时,重新唤醒队列中等待的线程的策略的不一样。
锁的最佳实践
1)、永远只在更新 对象的成员变量 时加锁
2)、永远只在访问 可变的成员变量 时加锁
3)、永远不在调用 其他对象的方法 时加锁
11、异步转同步
Condition 实现的管程支持多个条件变量。
阻塞队列需要两个条件变量:一个队列不空(空队列不允许出队),另一个是队列不满(队列已满不允许入队)。
Lock 和 Condition 实现的管程,线程等待和通知需要调用 awit()、signal()、signalAll()。sync 实现的管程中调用的是 wait()、notify()、notifyall() 方法。
同步和异步
调用方法是否等待结果。等待则为同步,不用等待则为异步。
Java 代码默认同步。可以在方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接 return,叫做 异步方法。
也可以在调用放创建一个子线程,在子线程中执行方法,叫做 异步调用。
12、ReadWriteLock
有了管程 Lock 和 Condition 和 信号量,Java SDK 里还有其他并发包,是为了 分场景优化性能,提升易用性。
缓存适用于 读多写少场景,Java SDK 提供了读写锁 -- ReadWriteLock。
基本原则:
允许多个线程同时读共享变量;
只允许1个线程写共享变量;
一个线程写共享变量时,其他线程不可读共享变量。
写操作和其他写、读操作都互斥。读操作和写互斥,和其他读操作互不影响。
快速实现一个缓存
HashMpa是线程不安全的,所以操作缓存 get、put 方法时,要进行加锁操作。
一个缓存类中2个方法,读和写方法,读加读锁,写加写锁。用 ReenReadWriteLock 中的 readLock() 和 WriteLock() 实现读锁、写锁。在 finally 中释放锁。
实现缓存的按需加载
在查询缓存时,加了读锁去读取缓存。如果缓存中存在,则返回数据,如果缓存中不存在,则需要再次验证缓存中是否存在了数据。
因为在高并发的场景下,可能会有多线程竞争写锁。如果线程 A 已经查询了数据库并且写入了缓存,但是线程 B、C 等会在此查询数据库。
所以在此验证后,可以避免高并发场景下重复查询数据的问题。
读写锁的升级与降级
ReadWritrLock 锁的升级是不被允许的。降级可以,从写锁降级到读锁。
13、StampedLock:比读写锁更快的锁
读多写少的场景, JDK 1.8 还提供了 StampedLock 锁,比读写锁性能好。
支持的三种锁模式:写锁、悲观读锁和乐观读。
其中 写锁、悲观读锁和 ReadWriteLock 的语义很类似。不同的是 StampedLock 里的写锁和悲观锁加锁成功之后,都会返回一个 stamp。解锁的时候,需要传入这个 stamp。(long stamp = sl.readLock(),sl.unlockRead(stamp))
乐观读是无锁的,多线程读的时候,允许一个线程获取写锁。
如果执行乐观读期间,存在写操作,则会把乐观读升级为悲观读锁。
乐观读
数据库中的乐观锁一般是在表中加一个 version 字段,每次去修改表中一行的值时,先根据主键 id 查询语句,取出 version 版本号值。再根据主键 id 修改,将 version 带入,和该行中数据的 version 对比,是否相等。相等,则修改 sql 则会返回 1。
乐观锁和乐观读很类似,乐观锁加了 version,乐观读是返回 stamp。
StampedLock 使用注意事项
读多写少的场景基本可以取代 ReadWriteLock。
StampedLock 不支持重入。
StampedLock 的悲观读锁、写锁都不支持条件变量。
使用 StampedLock 一定不要调用中断操作。如果需要中断,一定使用可中断的悲观读锁和写锁。
14、CountDownLatch 和 CyclicBarrier:让多线程步调一致
如果我们用3个线程来执行 操作A、操作B、操作C,操作C 是对操作A 和操作B 结果的一个汇总计算。那可以创建两个线程 T1、T2 来执行操作A、操作B。
并且调用 T1.join() 和 T2.join() 来实现等待。当线程 T1 和 T2 线程退出时, T1.join() 和 T2.join() 之后的主线程就会从阻塞态唤醒,执行之后的操作C。
用CountDownLatch 实现线程等待
主要用来解决一个线程等待多个线程的场景。
但是每次都创建出来新线程,太消耗时间,降低性能。用线程池。
但是线程池里没有线程,就没有 join() 方法。
用 CountDownLatch 工具类,计数器的初始值等于 2(该值为除开主线程多少个其他线程执行),在每个线程语句执行后计数器执行 latch.countDown 减1操作。
最后调用 lacth.await() 来实现计数器等于 0 的等待。
CyclicBarrier 实现线程同步
主要是一组线程之间互相等待
该工具类的计数器有自动重置的功能,当减到 0 时,会自动恢复到设置的初始值。。
15、并发容器的 坑
容器四大类:LIst、Map、Set、Queue
ArrayList、HashMap 线程不安全
Collenctions 将 ArrayList、HashSet 和 HashMap 包装成了线程安全的 List、Set 和 Map。(Collenctions.synchronizedList(new ArrayList()))
迭代器遍历容器,就会存在并发问题。所以要锁住 list 再进行操作。
同步容器,性能差。
并发容器:依旧是LIst、Map、Set、Queue
CopyOnWriteArrayList:写的时候将共享变量新复制出来一份,好处是读操作无锁。
将 array 复制一份,在新复制处理的数组上执行增加操作,然后再将 array 指向这个新的数组。仅适用于 写操作非常少的场景,能容忍读写的短暂不一致。并且迭代器是只读的,不支持增删改。迭代器遍历只是一个快照。
ConcurrentHashMap:key 是无序的(ConcurrentSkipListMap 的 key 是有序的)。
key 不能为空。
(Java7中的HashMap在执行put操作时会涉及到扩容,由于扩容时链表并发操作会造成链表成环,所以可能导致cpu飙升100%。)
16、原子类:无锁工具类的典范
解决 count+=1,,可见性用 volatile 解决,原子性用 互斥锁 解决。
原子性,还有种 无锁方案。
可以将 long 型变量 count 替换为原子类 AtomicLong,原来的 count+=1 替换为 count.getAndIncrement()。
无锁方案相对于 互斥锁 方案,最大好处是 性能。互斥锁为了保证互斥性,需要加锁、解锁操作,消耗性能,拿不到锁的线程还会进入阻塞状态,线程切换对性能的消耗也很大。
无锁方案的实现原理
CPU 为了解决并发问题,提供了 CAS 指令,能保证 原子性。
CAS 一般也会伴随着自旋,就是循环尝试。
原子类还是有很多问题,一般来说还是用 互斥锁 方案。
17、Executor 与线程池
线程是一个重量级的读写,应该避免频繁创建和销毁。就要用到线程池。
线程池是一种生产者-消费者模式。线程池的使用方是生产者,线程池本身是消费者。(工作线程负责消费任务,并执行任务)
Java 中的线程池
JDK 并发包中的并发工具类最核心的就是 ThreadPoolExecutor 。
构造参数:
corePoolSize:线程池核心的最少线程数。
maximumPoolSize:线程池创建的最大线程数。
keepAliveTime & unit:线程保持存活的时间。超过该时间,并且线程数大于核心线程数,就会回收该线程。
workQueue:工作队列。
threadFactory:自定义如何创建线程。
handler:自定义拒绝策略。
使用线程池的注意事项
建议使用有界队列,所以现在很少使用 Executors 。
默认的拒绝策略要慎重使用,因为会抛异常,而代码中很少会 catch。要自定义拒绝策略,配合降级策略使用。
还要注意异常处理的问题。
18、Future:获取多线程任务的运行结果
Java 通过 ThreadPoolExecutor 提供的3个 submit() 方法(都返回 future 接口)和1个 FutureTask 工具类再支持获得任务执行结果。
提交Runnable任务 submit(Runnable task):Runnable 的 run() 方式是没有返回值的,所以返回的 Future 只能断言任务已经结束。
提交Callable任务 submit(Callable<T> task):该方法参数是一个 Callable 接口,它的 call 方法是有返回值的,所以这个方法返回的 future 对象可以调用其 get() 方法获取到任务执行的结果。
还有个 submit() 方法。
FutureTask 工具类:有两个构造函数,参数分别是 Callable 和 Runnable、result。
Future 实现了 Runnable 和 Futrue 接口。所以可以将 FutureTask 对象作为任务提交给 ThreadPoolExecution 去执行,也可以直接被 thread 执行。
JDK 并发包提供的 Future 可以很容易获取异步任务的执行结果。
利用多线程可以快速将一些串行的任务并行化,提高性能。
如果任务之间互相有依赖关系,基本可以用 Future 来解决。最好是画流程图。
19、CompletableFuture 异步编程
多线程优化性能,不过时将串行操作变为并行操作。并且会涉及到异步。
创建 completableFuture
4个静态方法。默认情况下使用公共的 ForkJoinPool 线程池。但是可以指定线程池参数。一般情况都根据不同业务创建不同的线程池,避免互相干扰。
对于异步操作,关注两个问题:一个是异步操作什么时候结束,另一个是如何获取异步操作的执行结果。
重点
双重检查锁单例模式会产生什么bug
可能会产生并发 bug
因为创建对象分三个步骤,分配内存、初始化对象、赋值对象。但是 JVM 优化了代码,可能将该步骤重排序为:分配内存、赋值对象、初始化对象。
重排序就可能导致 外层 singleton == null 判断不为null。
解决方法:加入内存屏障,禁止重排序,加 volatile 字段即可。
(初始化对象:加载构造函数。
创建对象要加载类,类加载初始化阶段有个 client,对象创建的构造函数有一个 init 方法)