前言
随着时代的发展, CPU 核数的增加和计算速度的提升, 串行化的任务执行显然是对资源的极大浪费, 掌握多线程是每个程序员必须掌握的技巧. 但是同时多线程也是一把双刃剑, 带来了共享资源安全的隐患. 在本节会介绍线程安全是什么, 最基本的独占悲观式来保证线程安全的介绍. 随着章节步步深入.
1.1 什么是线程安全?
1.1.1 初识线程安全的尴尬
本人是 17 年毕业的, 刚进第一家公司的时候没有开发经验, 对接第三方支付公司外部 API 压测的时候碰到一个问题: 对方要求我 5 次 / s, 一共发 300s 的付款请求. 其中一个请求的 id, 保证当日每一次唯一. 我请求的 id 从 1 开始递增, 但是总是也达不到 300*5=1500. 也闹出了很尴尬的笑话. 这是我第一次接触多线程. 为了简化问题, 例子如下: 2 个线程对一个数字递增加 2000 次, 看看是否最后是 2000.
- /**
- * 多线程递增某一个数字的测试类.
- *
- * @author GrimMjx
- */
- public class UnsafeAdd {
- private int i;
- public int getNext() {
- return i++;
- }
- public static void main(String[] args) throws InterruptedException {
- UnsafeAdd multiAdd = new UnsafeAdd();
- Thread thread1 = new Thread(() -> {
- for (int i = 0; i <1000; i++) {
- multiAdd.getNext();
- }
- });
- Thread thread2 = new Thread(() -> {
- for (int i = 0; i <1000; i++) {
- multiAdd.getNext();
- }
- });
- thread1.start();
- thread2.start();
- // 请结合上一章节体会为何写下面 2 行
- thread1.join();
- thread2.join();
- System.out.println(multiAdd.i);
- }
- }
运行的结果 99% 都不是自己想要的结果, 说明这边出现了线程安全的问题. 除了这个例子相信很多同学都会听到类似这种话 "HashMap 不是线程安全的","不可变对象一定是线程安全的" 等等. 这些都是在说线程安全方面的话题, 之后的源码分析专题会分析为什么 HashMap 不是线程安全的, 取而代之的 ConcurrentHashMap 如何保证线程安全的? 同时 JDK6 引入 ConsurrentSkipListMap 和 ConcurrentSkipListSet 分别作为同步的 SortedMap 和 SortedSet 的并发替代品, 还有用 synchronizedxxx() 方法包装的 Map.
1.1.2 线程安全的概念
对于线程安全的概念, 参考《Java Concurrency in Practice》中的一句对线程安全的定义: 当多个线程访问某个类时, 这个类始终都能表现出正确的行为, 那么这个类就是线程安全的.
1.1.3 Race condition(竞态条件)
现在我们来分析一下上面的数据不一致问题, 这种情况成为竞态条件, 为什么会出现这个问题? UnsafeAdd 的问题在于, 线程的执行是由 CPU 时间片轮询调度的, 如果执行的时机不对, 那么可能在调用 getNext() 方法的时候得到一样的值, 或者某些值被忽略等. 主要是 i++; 看起来是一个原子操作, 但是它包含了 3 个独立的操作: 读取 i, 将 i+1, 并将计算结果写入 i. 简单画一张图, 如下:
线程 A 和线程 B 可能都读到 i 的变量为 10, 所以可能导致重复的情况, 造成达不到 2000 的效果.
1.2 初识保证线程安全的基本方法
1.2.1 synchronized 关键字
什么是 synchronized? 引入一段来自 JDK 官网对 synchronized 关键字比较权威的解释: Synchronized keyword enable a simple strategy for preventing thread interference and memory consistency errors: if an object is visible to more than one thread, all reads or writes to that object's variables are done through synchronized methods. 如果一个对象对多线程是可见的, 那么对改对象的读写操作都将通过同步的方式进行. 网上对他的讲解千千万, 很多都是一样的. 接下来讲一下我对他的具体表现:
synchronized 关键字用到的是 monitor enter 和 monitor exit 两个 JVM 指令 (请用 javap 命令自行研究), 且遵循 happens-before 规则. 能保证在 monitor enter, 获取到锁之前必须从主内存获取数据, 而不是线程的本地内存. 在 monitor exit 之后变量会刷新到主内存.(这里和上面的图都涉及到 JMM 模型, 这是并发的基础, 后面章节会详细介绍)
"synchronized 是一把锁", 这种理解是不严谨的. 准确的来说是某线程获取了对象的 monitor 锁, 在没有释放该锁之前, 其他线程在同一时刻无法获取该锁.
synchronized 可以用于对代码块或者方法进行修饰, 不能对变量进行修饰
如果要解决之前的问题, 那么在 getNext() 方法上加上 synchronized 关键字就可以解决了问题. 原因就是上面提到的, 当某个线程获取了 monitor 锁, 那么其他线程是无法获取锁的. 也就是说其他线程都无法执行该方法, 直到其他线程放弃该锁. 每一个内置锁都有且只能有一个相关联的条件队列 (这里的设计是否好呢?), 当一个线程获取锁进行操作的时候, 其他线程都在这个队列里等待该锁. 那么解决掉问题也了解最基本的保证线程安全的方法之后, 我们来看一下 JDK 对 synchronized 的优化以及 synchronized 的弊端.
1.2.2 synchronized 的优化
自旋锁
自旋锁在 JDK1.4 引入, 在 JDK1.6 默认开启. 自旋锁到底是什么呢? 之前我们说的互斥锁对性能的影响很大, Java 线程是映射到操作系统的原生线程上的, 如果要阻塞或者唤醒一个线程就需要操作系统的帮助, 因此状态转换需要花费很多 CPU 时间. 因为锁定的状态一般只会持续很短很短的时间, 为了这段时间去挂起然后再唤醒是很不值得的. 如果服务器有多个处理器, 我们就可以让后面的线程稍微等等, 但是并不放弃 CPU 执行时间, 这个稍微等等的过程就是自旋.
自旋锁和阻塞锁很大的区别就是是否要放弃 CPU 执行时间
锁消除
锁消除是 JIT 编译器对锁的具体实现所做的一种优化, 如果同步块所使用的锁对象通过逃逸分析出只有一个线程会访问, 那么 JIT 编译器在编译这个同步块的时候会消除同步
锁粗化
如果在一段代码中对一个对象反复加锁解锁, 那么会放宽锁的范围, 减少性能消耗.
如以下代码:
- for(int i=0;i<100000;i++){
- synchronized(this){
- do();
- }
粗化成:
- synchronized(this){
- for(int i=0;i<100000;i++){
- do();
- }
1.2.3 synchronized 的死穴: 锁是慢的
虽然内置锁优化至今已经和显式锁相差无几, 但是, 它的死穴就是: 锁是慢的. 让我们来做一个实验, 一个单线程对一个数字相加 1kw 次, 加锁和不加锁的时间的对比.
- /**
- * 对比有无锁的测试类.
- *
- * @author GrimMjx
- */
- public class CompareLockTest {
- private int i = 0;
- private int y = 0;
- public void addWithNoLock() {
- i++;
- }
- public synchronized void addWithLock() {
- y++;
- }
- public static void main(String[] args) {
- // no lock
- CompareLockTest noLockTest = new CompareLockTest();
- StopWatch stopWatch = new StopWatch();
- stopWatch.start();
- for (int index = 0; index < 10000000; index++) {
- noLockTest.addWithNoLock();
- }
- stopWatch.stop();
- System.out.println("no lock:" + stopWatch.getTotalTimeMillis());
- // with lock
- stopWatch.start();
- for (int index = 0; index < 10000000; index++) {
- noLockTest.addWithLock();
- }
- stopWatch.stop();
- System.out.println("with lock:" + stopWatch.getTotalTimeMillis());
- }
- }
结果不加锁的大概是 7 毫秒, 加锁大概是 250 毫秒. 这还只是单线程, 如果是多线程呢? 并发很难而锁的性能糟糕. 线程就像是两兄弟为一个玩具争吵, 操作系统就像是父母来决定他们谁拿玩具.
1.2.4 如何加锁
我们碰到最多的问题就是若没有则添加, 我们来看一个例子, 先写一个错误的加锁方式, 后写一个正确的方式.
- /**
- * list 测试类.
- *
- * @author GrimMjx
- */
- public class ListTest {
- public List<String> list = Collections.synchronizedList(new ArrayList<String>());
- /**
- * 非线程安全
- *
- * @param element
- * @return
- */
- public synchronized boolean unsafePutIfAbsent(String element) {
- boolean absent = !list.contains(element);
- if (absent) {
- list.add(element);
- }
- return absent;
- }
- /**
- * 线程安全
- *
- * @param element
- * @return
- */
- public boolean safePutIfAbsent(String element) {
- synchronized (list) {
- boolean absent = !list.contains(element);
- if (absent) {
- list.add(element);
- }
- return absent;
- }
- }
- // ... 其他对 list 操作的方法
- }
第一个方法为何不是线程安全的? 方法不是也已经用 synchronized 修饰了么? 这个 list 也是线程安全的. 对不对? 问题在于在错误的锁上进行了同步, 只是带来了同步的假象, 这就意味着该方法相对于 List 的其他操作来说并不是原子的. 因此无法确保当方法执行的时候, 另外一个线程不会修改链表.
第二个方法是正确的线程安全的, 最重要的是因为 list 在外部加锁时要使用同一个锁. 对于使用 list 的代码, 使用 list 本身用于保护其状态的锁来保护这段代码. 说白了就是你要知道你获取的什么锁, 锁的是什么对象, 这个是一定要搞清楚的.
1.3 死锁
1.3.1 死锁的介绍
在多线程访问共享资源的情况下, 如果对线程驾驭不当很容易引起死锁的情况发生. 死锁又分: 交叉锁, 数据库锁等. 比如说数据库锁, 如果 A 线程执行了 select xxx for update 语句退出了事务, 那么别的线程访问都将陷入死锁. 简而言之, 死锁说白了就是 "我在等你, 你也在等我". 还是写个例子吧.
- /**
- * 死锁测试类.
- *
- * @author GrimMjx
- */
- public class DeadLockTest {
- public static void main(String[] args) {
- Object a = new Object();
- Object b = new Object();
- new Thread(()->{
- synchronized (a) {
- System.out.println("已经锁住 a 了");
- synchronized (b){
- System.out.println("同时锁住 a 和 b 了");
- }
- }
- }).start();
- new Thread(()->{
- synchronized (b) {
- System.out.println("已经锁住 b 了");
- synchronized (a){
- System.out.println("同时锁住 a 和 b 了");
- }
- }
- }).start();
- }
- }
如果 A 线程已经获取 a 对象的锁, 现在想要获取 b 对象的锁. 此时 B 线程已经获取 b 对象的锁, 想要获取 a 对象的锁. 那么如果两个线程都不释放已经持有对象的锁, 大家都无法拿到第二个对象的锁. 如果程序出现死锁, 可以利用 jstack 等工具进行分析.
来源: https://www.cnblogs.com/GrimMjx/p/10049342.html