混混噩噩看了很多多线程的书籍,一直认为自己还不够资格去阅读这本书。有种要高登大堂的感觉,被各种网络上、朋友、同事一顿外加一顿的宣传与传颂,多多少少再自我内心中产生了一种敬畏感。2月28好开始看了之后,发现,其实完全没这个必要。除了翻译的烂之外(一大段中文下来,有时候你就会骂娘:这tm想说的是个shen me gui),所有的,多线程所必须掌握的知识点,深入点,全部涵盖其中,只能说,一书在手,万线程不愁那种!当然,你必须要全部读懂,并融汇贯通之后,才能有的效果。我推荐,看这本书的中文版本,不要和哪一段的冗长的字句在那过多的纠缠,尽量一段一段的读,然后获取这一段中最重要的那句话,否则你会陷入中文阅读理解的怪圈,而怀疑你的高中语文老师是不是体育老师客串的!!我举个例子:13页第八段,我整段读了三遍硬是没想明白前面那么多的文字,是干什么用的,就是最后一句话才是核心:告诉你,线程安全性,最正规的定义应该是什么!(情允许我,向上交的几个翻译此书的,所谓的“教授”致敬,在你们的引领下,使我们的意志与忍受力更上了一个台阶,人生更加完美!)
一、多线程开发所要平衡的几个点
看了很多次的目录,外加看了第一部分,发现,要想做好多线程的开发,无非就是平衡好以下的几点
- 安全性
- 活跃性
- 无限循环问题
- 死锁问题
- 饥饿问题
- 活锁问题(这个还没具体的了解到)
- 性能要求
- 吞吐量的问题
- 可伸缩性的问题
二、多线程开发所要关注的开发点
要想平衡好以上几点,书中循序渐进的将多线程开发最应该修炼的几个点,娓娓道来:
- 原子性
- 先检查后执行
- 原子类
- 加锁机制
- 可见性
- 重排
- 非64位写入问题
- 对象的发布
- 对象的封闭
- 不变性
在一本国人自己写的,介绍线程工具api的书中,看到了这么一句话:外练原子,内练可见。感觉这几点如果在多线程中尤为重要。我在有赞,去年还记得上线多门店的那天凌晨,最后项目启动报一个类加载的错误,一堆人过来看问题,基德大神站在攀哥的后面,最后淡淡的说了句:已经很明显是可见性问题了,加上volatile,不行的话,我把代码吃了!!可以见得,多线程这几个点,在“居家旅行”,生活工作中是多么的常见与重要!不出问题不要紧,只要一出,就会是头痛的大问题,因为你根本不好排查根本原因在这。所以我们需要平时就练好功底,尽量避免多线程问题的出现!而不是一味的用框架啊用框架、摞代码啊摞代码!
三、原子性下面的安全问题
1. 下面代码有什么问题呢?
public class UnsafeConuntingFactorizer implements Servlet{ private long count = 0; private long getCount(){ return count; } public void service(ServletRequest req, ServletResponse resp){ BigInteger i = extractFromRequest(req); BigInteger[] factors = factor(i); ++count; encodeIntoResponse(resp,factor); }}
思考:如何让一个普普通通的类变得线程安全呢?一个类什么叫做有状态,而什么又叫做无状态呢?
2. 上面代码分析
- 一个请求的方法,实例都是一个,所以每次请求都会访问同一个对象
- 每个请求,使用一个线程,这就是典型的多线程模型
- count是一个对象状态属性,被多个线程共享
++count
并非一次原子操作(分成:复制count->对复制体修改->使用复制体回写count,三个步奏)- 多个线程有可能多次修改count值,而结果却相同
3. 使用原子类解决上面代码问题
public class UnsafeConuntingFactorizer implements Servlet{ private final AtomicLong count = new AtomicLong(0); private long getCount(){ return count.get(); } public void service(ServletRequest req, ServletResponse resp){ BigInteger i = extractFromRequest(req); BigInteger[] factors = factor(i); count.incrementAndGet();//使用了新的原子类的原子方法 encodeIntoResponse(resp,factor); }}
4. 原子类也不是万能的
//在复杂的场景下,使用多个原子类的对象public class UnsafeConuntingFactorizer implements Servlet{ private final AtomicReferencelastNumber = new AtomicReference (); private final AtomicReference lastFactors = new AtomicReference (); public void service(ServletRequest req, ServletResponse resp){ BigInteger i = extractFromRequest(req); if(i.equals(lastNumber.get())){//先判断再处理,并没有进行同步,not safe! encodeIntoResponse(resp,lastFactors.get()); }else{ BigInteger[] factors = factor(i); lastNumer.set(i); lastFactors.set(factors); encodeIntoResponse(resp, factors); } }}
思考:什么叫做复合型操作?
5. 先列举一个我们常见的复合型操作
public class LazyInitRace { private ExpensiveObject instace = null; public ExpensiveObject getInstace(){ if(instace == null){ instace = new ExpensiveObject(); } return instace; }}
看好了,这就是我们深恶痛绝的一段代码!如果这段代码还分析不了的,对不起,出门左转~
6. 提高“先判断再处理”的警觉性
- 如果没有同步措施,直接对一个状态进行判断,然后设值的,都是不安全的
- if操作和下面代码快中的代码,远远不是原子的
- 如果if判断完之后,接下来线程挂起,其他线程进入判断流程,又是同样的状态,同样进入if语句块
- 当然,只有一个线程执行的程序,请忽略(那还叫能用的程序吗?)
7. 性能的问题来了
//在复杂的场景下,使用多个原子类的对象public class UnsafeConuntingFactorizer implements Servlet{ private final AtomicReferencelastNumber = new AtomicReference (); private final AtomicReference lastFactors = new AtomicReference (); //这下子总算同步了! public synchronized void service(ServletRequest req, ServletResponse resp){ BigInteger i = extractFromRequest(req); if(i.equals(lastNumber.get())){//先判断再处理,并没有进行同步,not safe! encodeIntoResponse(resp,lastFactors.get()); }else{ BigInteger[] factors = factor(i); lastNumer.set(i); lastFactors.set(factors); encodeIntoResponse(resp, factors); } }}
思考:有没有种“关公挥大刀,一砍一大片”的感觉?
8. 上诉代码解析
- 加上了
synchronized
关键字的确解决了多线程访问,类安全性问题 - 可是每次都是一个线程进行计算,所有请求变成了串行
- 请求量低于100/s其实都还能接受,可是再高的话,这就完全有问题的代码了
- 性能问题,再网络里面,是永痕的心病~
9. 一段针对原子性、性能问题的解决方案
//在复杂的场景下,使用多个原子类的对象public class CacheFactorizer implements Servlet{ private BigInteger lastNumber; private BigInteger[] lastFactors ; private long hits; private long cacheHits; public synchronized long getHits(){ return hits; } public synchronized double getCacheHitRadio(){ return (double) cacheHits / (double) hits; } public void service(ServletRequest req, ServletResponse resp){ BigInteger i = extractFromRequest(req); BigInteger[] factors = null; synchronized (this){ ++hits; if(i.equals(lastNumber)){ ++cacheHits; factors = lastFactors.clone(); } } if (factors == null){ factors = factor(i); synchronized (this){ lastNumer = i; lastFactors = factors.clone(); } } encodeIntoResponse(resp, factors); }}
在修改状态值的时候,才进行加锁,平时对状态值的读操作可以不用加锁,当然,最耗时的计算过程,也是要同步的,这种情况下,才会进一步提高性能。
四、可见性下面的对象共享
可见性这个话题,在多线程的环境下,是相当棘手的。可能多年之后,你成为万人心中的老鸟,也同样会对这个问题,怅然若失!我自己总结了几点,可见性问题的难处:
- 道理简单,真实场景代码错中复杂,你根本不知道是可见性导致的
- 小概率事件,往往可能只有百分之一,甚至千分之一的出事概率,容易被我们“得过且过”
- 容易直接扔到一个
synchronized
加锁块里面,进行“大刀”式的处理,而忽略了高效性 - 可见性+原子性的综合考虑
针对这些问题,我们只能先从基本功抓起,然后在日积月累的开发工作中,多多分析程序运行的场景,多多尝试,才能大有裨益。
插曲:昨天看了《恋爱回旋》这部日本电影,其中有个场景让我记忆深刻:女主是小时候被魔鬼母亲常年训练的乒乓球少年运动员,后来总总原因,放弃了乒乓球,当起了OL,这一别就是15年。当再次碰到男主的时候,男主向女主发起乒乓球挑战,以为女主是个菜逼,然后赌一些必须要让女主完成的事情。(女主本人也是觉得乒乓球对自己是一种心理的负担,并且放弃这么久了,所以没啥子自信)没想到,女主一拿球拍,在接发球的那一刹那。。。。。大家应该都懂了。我当时就在影院中说出声来:基本功太重要了。
1. 可见性的发生的必要条件
可见性,无非就是再多线程环境下,对共享变量的读写导致的。可能一个线程修改了共享变量的值,而另一个线程读取的还是老的值,差不多就是这么大白话的解释了下来。其中发生的必要条件有:
- 多线程环境访问同一个共享变量
- 服务器模式下启动程序
- 共享变量并没有做什么处理,代码块也没有同步
当然,要分析为什么会有可见性的问题,要结合JVM虚拟机内存模型分析。以后会在《深入理解Java虚拟机》的学习中,做详细的分析,敬请期待。
2. 不多说上代码
public class NoVisibility{ private static boolean ready; private static int number; private static class ReaderThread extends Thread{ public void run(){ while(!ready){ Thread.yield(); } System.out.println(number); } } public static void main(String[] args){ new ReaderThread().start(); number = 42; ready = true; }}
思考:上面的打印number的值,有可能会有几种结果呢?什么情况下出现的这些个结果
3. 上诉代码分析
- number最终打印结果有可能出现42、0,或者根本就不会打印
- 42:这种情况是运行正确的结果
- 0:这种情况发生了指令重排(五星级的问题)
- 不会打印:主线程对ReaderThread线程出现了共享变量不可见
4. “愚钝”的聊聊指令重排
之所以说是“愚钝”,原因是重排问题,是一个很底层很考验计算机基础能力的一个问题,小弟不才,当年分析计算机组成原理与指令结构的时候,枯燥极致,都睡过去了。现在回头,才知道其重要性。现阶段,对重排的分析,我只能举例个简单的例子,进行说明,更进一步的分析,同样是要结合JVM的机制(六大Happens-before)来分析,以后再做进一步,详尽的分析。下面就是那个简单的例子:
//简单例子public class OrderConfuse{ public static void main(String[] args){ int a = 1; int b = 2; int c = a+b; int d = c+a; System.out.println(c); System.out.println(d); }}
- 上面程序是正确的,也能正确输出
- 对a和b的赋值操作,并非先赋值a再赋值b的
- 原因是JVM底层会对指令进行优化,保证程序的快速执行,其实就是一种效率优化
- 变量c会用到a和b变量,所以a和b的操作必须要发生在c之前(happens-before)
- 有可能b进行了赋值,而a还是初始化的状态,就是值为0
所以结合前面的代码段:
public class NoVisibility{ ...... public static void main(String[] args){ new ReaderThread().start(); number = 42; ready = true; }}
- number和ready之后,并没有使用它们的变量了
- number和ready会被进行指令重排
- 结果就是:ready已经赋值变成了true,可是number还是0
这就是为啥会为零的原因所在!
5. 针对可见性,还是要上JVM的内存模型进行简单分析
- 每个线程都会有自己的线程虚拟机栈
- 栈上面存储原始类型和对象类型的引用
- 每次启动一个线程,都会在对共享数据进行一次复制,复制到每个线程的虚拟机栈中
- 上面的number是在主线程中,同时在ReaderThread线程的虚拟机栈中有一个副本
- 各个虚拟机栈最终要进行同步,才能保持一致
- 所以每次修改一个共享变量(原始类型)其实是在本地线程空间里面修改
- number在主线程里面修改了,可是在ReaderThread线程里面并没有修改,因为两个线程访问的空间并不一样,一个线程对另一个线程空间并不可见。
6. volatile关键字横空出世
volatile关键字的作用,主要有一下几点:
- 能把对变量的修改,马上同步到主存中
- 各个线程立马更新自己线程栈中的变量值
- 防止指令重排
- 无法保证原子性
对于最底层如何做到这些个点的,具体还可以分析,例如什么内存屏障、状态过期等等,完全可以聊一个专题,今天再次先不聊,同样放到《深入理解JVM虚拟机》的学习中来详尽分析。所以,上面程序可以改成下面这个样子:
public class Visibility{ private static volatile boolean ready;//注意这个类型 private static volatile int number;//注意这个类型 private static class ReaderThread extends Thread{ public void run(){ while(!ready){ Thread.yield(); } System.out.println(number); } } public static void main(String[] args){ new ReaderThread().start(); number = 42; ready = true; }}
7. synchronized关键字同样可以保证可见性
public class Visibility{ private static boolean ready; private static int number; private static Object lock = new Object(); private static class ReaderThread extends Thread{ public void run(){ while(!ready){ Thread.yield(); } System.out.println(number); } } public static void main(String[] args){ new ReaderThread().start(); synchronized(lock){//这里进行了加锁 number = 42; ready = true; } }}
- 加锁可以同时保证可见性与原子性
- 加锁同样可以防止指令重排,内部代码都会照顺序执行
8. volatile不是万能的
public class VisibleNotAtomic{ private static volatile int number = 1; private static class ReadThread extends Thread{ public void run(){ if(number == 2){ System.out.println("correct!"); }else{ System.out.println("error!"); } } } public static void main(String[] args){ number++; }}
- number是对主线程和ReadThread线程都可见的
- 可是number++不是原子操作
- 加加到了一半,主线程挂起,ReadThread线程运行,number的值还是1,输出error
五、第一部分总结
我们主要讲了线程的原子性和可见性,结合代码,不知不觉就讲了一堆,而且感觉还可以在讲~~多线程的话题真的是太恐怖了!未来的可预见性的规划如下:
- 对象的安全发布
- 对象的不变性
- 对象的合理加锁
- 生产者消费者模型
- 构建高效可伸缩的缓存
恩,敬请期待~