1. 前言
最近偶然间看见一道名为史上最难的 java 面试题, 这个题让了我对线程安全的有了一些新的思考, 给大家分享一下这个题吧:
- public class TestSync2 implements Runnable {
- int b = 100;
- synchronized void m1() throws InterruptedException {
- b = 1000;
- Thread.sleep(500); //6
- System.out.println("b=" + b);
- }
- synchronized void m2() throws InterruptedException {
- Thread.sleep(250); //5
- b = 2000;
- }
- public static void main(String[] args) throws InterruptedException {
- TestSync2 tt = new TestSync2();
- Thread t = new Thread(tt); //1
- t.start(); //2
- tt.m2(); //3
- System.out.println("main thread b=" + tt.b); //4
- }
- @Override
- public void run() {
- try {
- m1();
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
复制代码
推荐大家先别急着看下面的答案, 试着看看这个题的答案是什么? 刚开始看这个题的时候, 第一反应我擦嘞, 这个是哪个老铁想出的题, 如此混乱的代码调用, 真是惊为天人. 当然这是一道有关于多线程的题, 最低级的错误, 就是一些人对于. start()和. run 不熟悉, 直接会认为. start()之后 run 会占用主线程, 所以得出答案等于:
- main thread b=2000
- b=2000
复制代码
比较高级的错误: 了解 start(), 但是忽略了或者不知道 synchronized, 在那里瞎在想 sleep()有什么用, 有可能得出下面答案:
- main thread b=1000
- b=2000
复制代码
总而言之问了很多人, 大部分第一时间都不能得出正确答案, 其实正确答案如下:
- main thread b=2000
- b=1000
- or
- main thread b=1000
- b=1000
复制代码
解释这个答案之前, 这种题其实在面试的时候遇到很多, 依稀记得再学 C++ 的时候, 考地址, 指针, 学 java 的时候又在考 i++,++i,"a" == b 等于 True? 这种题屡见不鲜, 想必大家做这种题都知道靠死记硬背是解决不来的, 因为这种变化实在太多了, 所以要做这种比较模棱两可的题目, 必须要会其意, 方得齐解. 尤其是多线程, 如果你不知道其原理, 不仅仅在面试中过不了, 就算侥幸过了, 在工作中如何不能很好的处理线程安全的问题, 只能导致你的公司出现损失.
这个题涉及了两个点:
synchronized
线程的几个状态: new,runnable(thread.start()),running,blocking(Thread.Sleep())
如果对这几个不熟悉的同学不要着急下面我都会讲, 下面我解释一下整个流程:
新建一个线程 t, 此时线程 t 为 new 状态.
调用 t.start(), 将线程至于 runnable 状态.
这里有个争议点到点是 t 线程先执行还是 tt.m2 先执行呢, 我们知道此时线程 t 还是 runnable 状态, 此时还没有被 cpu 调度, 但是我们的 tt.m2()是我们本地的方法代码, 此时一定是 tt.m2()先执行.
执行 tt.m2()进入 synchronized 同步代码块, 开始执行代码, 这里的 sleep()没啥用就是混淆大家视野的, 此时 b=2000.
在执行 tt.m2()的时候. 有两个情况:
情况 A: 有可能 t 线程已经在执行了, 但是由于 m2 先进入了同步代码块, 这个时候 t 进入阻塞状态, 然后主线程也将会执行输出, 这个时候又有一个争议到底是谁先执行? 是 t 先执行还是主线程, 这里有小伙伴就会把第 3 点拿出来说, 肯定是先输出啊, t 线程不是阻塞的吗, 调度到 CPU 肯定来不及啊? 很多人忽略了一点, synchronized 其实是在 1.6 之后做了很多优化的, 其中就有一个自旋锁, 就能保证不需要让出 CPU, 有可能刚好这部分时间和主线程输出重合, 并且在他之前就有可能发生, b 先等于 1000, 这个时候主线程输出其实就会有两种情况. 2000 或者 1000.
情况 B: 有可能 t 还没执行, tt.m2()一执行完, 他刚好就执行, 这个时候还是有两种情况. b=2000 或者 1000
6. 在 t 线程中不论哪种情况, 最后肯定会输出 1000, 因为此时没有修改 1000 的地方了.
整个流程如下面所示:
2. 线程安全
对于上面的题的代码, 虽然在我们实际场景中很难出现, 但保不齐有哪位同事写出了类似的, 到时候有可能排坑的还是你自己, 所以针对此想聊聊一些线程安全的事.
2.1 何为线程安全
我们用java concurrency in practice中的一句话来表述: 当多个线程访问一个对象时, 如果不用考虑这些线程在运行时环境下的调度和交替执行, 也不需要进行额外的同步, 或者在调用方进行任何其它的协调操作, 调用这个对象的行为都可以获得正确的结果, 那这个对象就是线程安全的.
从上我们可以得知:
在什么样的环境: 多个线程的环境下.
在什么样的操作: 多个线程调度和交替执行.
发生什么样的情况: 可以获得正确结果.
谁 : 线程安全是用来描述对象是否是线程安全.
2.2 线程安全性
我们可以按照 java 共享对象的安全性, 将线程安全分为五个等级: 不可变, 绝对线程安全, 相对线程安全, 线程兼容, 线程对立:
2.2.1 不可变
在 java 中 Immutable(不可变)对象一定是线程安全的, 这是因为线程的调度和交替执行不会对对象造成任何改变. 同样不可变的还有自定义常量, final 及常池中的对象同样都是不可变的.
在 java 中一般枚举类, String 都是常见的不可变类型, 同样的枚举类用来实现单例模式是天生自带的线程安全, 在 String 对象中你无论调用 replace(),subString()都无法修改他原来的值
2.2.2 绝对线程安全
我们来看看 Brian Goetz 的Java 并发编程实战对其的定义: 当多个线程访问某个类时, 不管运行时环境采用何种调度方式或者这些线程将如何交替进行, 并且在主调代码中不需要任何额外的同步或协同, 这个类都能表现出正确的行为, 那么称这个类是线程安全的.
周志明在 <<深入理解 java 虚拟机>> 中讲到, Brian Goetz 的绝对线程安全类定义是非常严格的, 要实现一个绝对线程安全的类通常需要付出很大的, 甚至有时候是不切实际的代价. 同时他也列举了 Vector 的例子, 虽然 Vectorget 和 remove 都是 synchronized 修饰的, 但还是展现了 Vector 其实不是绝对线程安全. 简单介绍下这个例子:
- public Object getLast(Vector list) {
- return list.get(list.size() - 1);
- }
- public void deleteLast(Vector list) {
- list.remove(list.size() - 1);
- }
复制代码
如果我们使用多个线程执行上面的代码, 虽然 remove 和 get 是同步保证的, 但是会出现这个问题有可能已经 remove 掉了最后一个元素, 但是 list.size()这个时候已经获取了, 其实 get 的时候就会抛出异常, 因为那个元素已经 remove.
2.2.3 相对安全
周志明认为这个定义可以适当弱化, 把 "调用这个对象的行为" 限定为 "对对象单独的操作", 这样一来就可以得到相对线程安全的定义. 其需要保证对这个对象单独的操作是线程安全的, 我们在调用的时候不需要做额外的操作, 但是对于一些特定的顺序连续调用, 需要额外的同步手段. 我们可以将上面的 Vector 的调用修改为:
- public synchronized Object getLast(Vector list) {
- return list.get(list.size() - 1);
- }
- public synchronized void deleteLast(Vector list) {
- list.remove(list.size() - 1);
- }
复制代码
这样我们作为调用方额外加了同步手段, 其 Vector 就符合我们的相对安全.
2.2.4 线程兼容
线程兼容是指其对象并不是线程安全, 但是可以通过调用端正确地使用同步手段, 比如我们可以对 ArrayList 进行加锁, 一样可以达到 Vector 的效果.
2.2.5 线程对立
线程对立是指无论调用端是否采取了同步措施, 都无法在多线程环境中并发使用的代码. 由于 Java 语言天生就具备多线程特性, 线程对立这种排斥多线程的代码是很少出现的, 而且通常都是有害的, 应当尽量避免.
2.3 如何解决线程安全
对于解决线程安全一般来说有几个办法: 互斥阻塞(悲观, 加锁), 非阻塞同步(类似乐观锁, CAS), 不需要同步(代码写得好, 完全不需要考虑同步)
同步是指在多个线程并发访问共享数据时, 保证共享数据在同一个时刻只被一条线程 (或是一些, 使用信号量的时候) 线程使用.
2.3.1 互斥同步
互斥是一种悲观的手段, 因为他担心他访问的时候时刻有人会破坏他的数据, 所以他需要通过某种手段进行将这个数据在这个时间段给占为独有, 不能让其他人有接触的机会. 临界区 (CriticalSection), 互斥量(Mutex) 和信号量 (Semaphore) 都是主要的互斥实现方式. 在 Java 中一般用 ReentrantLock 和 synchronized 实现同步. 而实际业务当中, 推荐使用 synchronized, 在第一节的代码其实也是使用的 synchronized , 为什么推荐使用 synchronized 的呢?
如果我们显示的使用 lock 我们得手动的进行解锁 unlock()调用, 但是很多人在实际开发过程其实有可能出现忘记, 所以推荐使用 synchronized , 在易于编程方面 Lock 败.
synchronized 在 jdk1.6 之后对其进行了优化会从偏向锁, 轻量级锁, 自旋适应锁, 最后才到重量级锁. 而 Lock 一来就是重量锁. 在未来的 jdk 版本中, 重点优化的也是 synchronized. 在性能方便 Lock 也败.
如果你在业务中需要等待可中断, 等待超时, 公平锁等功能的话, 那你可以选择这个 ReentrantLock.
当然在我们的 Mysql 数据库中排他锁其实也是互斥同步的实现, 当加上排他锁, 其他事务都不能进行访问其数据.
2.3.2 非阻塞同步
非阻塞同步是一种乐观的手段, 在乐观的手段中他会先去尝试操作, 如果没有人在竞争, 就成功, 否则就进行补偿(一般就是死循环重试或者循环多次之后跳出), 在互斥同步最重要的问题就是进行线程阻塞和唤醒所带来的性能问题, 而乐观同步策略解决了这一问题.
但是上面就有个问题操作和检测是否有人竞争这两个操作一定得保证原子性, 这就需要我们硬件设备的支持, 例如我们 java 中的 cas 操作其实就是操作的硬件底层的指令.
在 JDK1.5 之后, Java 程序中才可以使用 CAS 操作, 该操作由 sun.misc.Unsafe 类里面的 compareAndSwapInt()和 compareAndSwapLong()等几个方法包装提供, 虚拟机在内部对这些方法做了特殊处理, 即时编译出来的结果就是一条平台相关的处理器 CAS 之类, 没有方法调用的过程, 或者可以认为是无条件内联进去了
2.3.3 无同步
要保证线程安全, 并不一定就要进行同步, 两者没有因果关系. 同步只是保障共享数据争用时的正确性手段, 如果一个方法本来就不涉及共享数据, 那它自然就无须任何同步措施去保证正确性, 因此会有一些代码天生就是现场安全的. 一般分为两类:
可重入代码: 可重入代码也叫纯代码, 可以随时中断, 恢复控制权之后程序依然不会出任何错误, 可重入代码的结果一般来说是可预测的:
- public int sum(){
- return 1+2;
- }
复制代码
例如这种代码就是可重入代码, 但是在我们自己的代码中其实出现得很少
线程本地存储: 而这个一般来说是我们用得比较多的手段, 我们可以通过保证类是无状态的, 所有的变量都存在于我们的方法之中, 或者通过 ThreadLocal 来进行保存.
2.4 线程安全的一些其他经验
上面写得都比较官方, 下面说说从一些真实的经验中总结出来的:
在使用某些对象作为单例的时候, 需要确定这个对象是否是线程安全的: 比如我们使用 SimpleDateFormate 的时候, 很多初学者都不注意将其作为单例一个工具类来使用, 导致了我们的业务异常. 可以参考我的另外一篇: 在 Java 中你真的会日期转换吗?
如果发现其不是单例, 需要进行替换, 比如 HashMap 用 ConcurrentHashMap,queue 用 ArrayBlockingQueue 进行替换.
注意死锁, 如果使用锁一定记得释放锁, 同时使用锁的顺序一定要注意, 这里不仅仅说的是单机的锁, 也要说分布式锁, 一定要注意: 一个线程先锁 A 后锁 B, 另一个线程先锁 B 后锁 A 这个情况. 所以一般来说分布式锁会加上超时时间, 避免由于网络问题释放锁失败, 而导致死锁.
锁的粒度: 同样的不仅仅是说单机的锁, 也包括了分布式锁, 不要图方便直接从入口方法, 不加分析的就开始加锁, 这样会严重影响性能. 同样的也不能过于细粒度, 单机的锁会增加上下文的切换, 分布式锁会增加网络调用, 都会导致我们性能的下降.
适当引入乐观锁: 比如我们有个需求是给用户扣款, 为了防止多扣, 这个时候会用悲观锁进行锁, 但是效率比较低, 因为用户扣款其实同时扣的情况是比较少的, 我们就可以使用乐观锁, 在用户的账户表里面添加 version 字段, 首先查询 version, 然后更新的时候看看当前 version 和数据库的 version 是否一致, 一致就更新不一致就证明已经扣过了.
如果想要在多线程环境下使用非线程安全对象, 数据可以放在 ThreadLocal, 或者只在方法里面进行创建, 我们的 ArrayList 虽然不是线程安全的, 但是一般我们使用的时候其实都是在方法里面进行 List list = new ArrayList()使用, 用无同步的方式也保证了线程安全.
毛主席曾说过: 手里有粮, 心里不慌. 多多学习多线程知识, 这个也是最重要的, 当然可以关注我的公众号来和共同进步.
最后
本文从最开始的一道号称史上最难的面试题, 引入了我们工作中最为重要之一的线程安全. 希望大家后续可以好好的阅读周志明的深入理解 jvm 虚拟机的第 13 章线程安全和锁优化, 相信读完之后一定会有一个新的提升. 由于作者本人水平有限, 如果有什么错误, 还请指正.
来源: https://juejin.im/post/5b632cbee51d45190f4af48e