13 | 理论基础模块热点问题答疑
下载APP
关闭
渠道合作
推荐作者
13 | 理论基础模块热点问题答疑
2019-03-28 王宝令 来自北京
《Java并发编程实战》
课程介绍
讲述:王宝令
时长12:26大小11.37M
到这里,专栏的第一模块——并发编程的理论基础,我们已经讲解完了,总共 12 篇,不算少,但“跳出来,看全景”你会发现这 12 篇的内容基本上是一个“串行的故事”。所以,在学习过程中,建议你从一个个单一的知识和技术中“跳出来”,看全局,搭建自己的并发编程知识体系。
为了便于你更好地学习和理解,下面我会先将这些知识点再简单地为你“串”一下,咱们一起复习下;然后就每篇文章的课后思考题、留言区的热门评论,我也集中总结和回复一下。
那这个“串行的故事”是怎样的呢?
起源是一个硬件的核心矛盾:CPU 与内存、I/O 的速度差异,系统软件(操作系统、编译器)在解决这个核心矛盾的同时,引入了可见性、原子性和有序性问题,这三个问题就是很多并发程序的 Bug 之源。这,就是01的内容。
那如何解决这三个问题呢?Java 语言自然有招儿,它提供了 Java 内存模型和互斥锁方案。所以,在02我们介绍了 Java 内存模型,以应对可见性和有序性问题;那另一个原子性问题该如何解决?多方考量用好互斥锁才是关键,这就是03和04的内容。
虽说互斥锁是解决并发问题的核心工具,但它也可能会带来死锁问题,所以05就介绍了死锁的产生原因以及解决方案;同时还引出一个线程间协作的问题,这也就引出了06这篇文章的内容,介绍线程间的协作机制:等待 - 通知。
08介绍的管程,是 Java 并发编程技术的基础,是解决并发问题的万能钥匙。并发编程里两大核心问题——互斥和同步,都是可以由管程来解决的。所以,学好管程,就相当于掌握了一把并发编程的万能钥匙。
至此,并发编程相关的问题,理论上你都应该能找到问题所在,并能给出理论上的解决方案了。
而后在09、10和11我们又介绍了线程相关的知识,毕竟 Java 并发编程是要靠多线程来实现的,所以有针对性地学习这部分知识也是很有必要的,包括线程的生命周期、如何计算合适的线程数以及线程内部是如何执行的。
并发编程理论基础模块思维导图
经过这样一个简要的总结,相信你此时对于并发编程相关的概念、理论、产生的背景以及它们背后的关系已经都有了一个相对全面的认识。至于更深刻的认识和应用体验,还是需要你“钻进去,看本质”,加深对技术本身的认识,拓展知识深度和广度。
另外,在每篇文章的最后,我都附上了一个思考题,这些思考题虽然大部分都很简单,但是隐藏的问题却很容易让人忽略,从而不经意间就引发了 Bug;再加上留言区的一些热门评论,所以我想着将这些隐藏的问题或者易混淆的问题,做一个总结也是很有必要的。
1. 用锁的最佳实践
03的思考题的示例代码如下,synchronized (new Object()) 这行代码很多同学已经分析出来了,每次调用方法 get()、addOne() 都创建了不同的锁,相当于无锁。这里需要你再次加深一下记忆,“一个合理的受保护资源与锁之间的关联关系应该是 N:1”。只有共享一把锁才能起到互斥的作用。
另外,很多同学也提到,JVM 开启逃逸分析之后,synchronized (new Object()) 这行代码在实际执行的时候会被优化掉,也就是说在真实执行的时候,这行代码压根就不存在。不过无论你是否懂“逃逸分析”都不影响你学好并发编程,如果你对“逃逸分析”感兴趣,可以参考一些 JVM 相关的资料。
04的思考题转换成代码,是下面这个样子。它的核心问题有两点:一个是锁有可能会变化,另一个是 Integer 和 String 类型的对象不适合做锁。如果锁发生变化,就意味着失去了互斥功能。 Integer 和 String 类型的对象在 JVM 里面是可能被重用的,除此之外,JVM 里可能被重用的对象还有 Boolean,那重用意味着什么呢?意味着你的锁可能被其他代码使用,如果其他代码 synchronized(你的锁),而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。
通过这两个反例,我们可以总结出这样一个基本的原则:锁,应是私有的、不可变的、不可重用的。我们经常看到别人家的锁,都长成下面示例代码这样,这种写法貌不惊人,却能避免各种意想不到的坑,这个其实就是最佳实践。最佳实践这方面的资料推荐你看《Java 安全编码标准》这本书,研读里面的每一条规则都会让你受益匪浅。
2. 锁的性能要看场景
《05 | 一不小心就死锁了,怎么办?》的思考题是比较while(!actr.apply(this, target));这个方法和synchronized(Account.class)的性能哪个更好。
这个要看具体的应用场景,不同应用场景它们的性能表现是不同的。在这个思考题里面,如果转账操作非常费时,那么前者的性能优势就显示出来了,因为前者允许 A->B、C->D 这种转账业务的并行。不同的并发场景用不同的方案,这是并发编程里面的一项基本原则;没有通吃的技术和方案,因为每种技术和方案都是优缺点和适用场景的。
3. 竞态条件需要格外关注
《07 | 安全性、活跃性以及性能问题》里的思考题是一种典型的竞态条件问题(如下所示)。竞态条件问题非常容易被忽略,contains() 和 add() 方法虽然都是线程安全的,但是组合在一起却不是线程安全的。所以你的程序里如果存在类似的组合操作,一定要小心。
这道思考题的解决方法,可以参考《12 | 如何用面向对象思想写好并发程序?》,你需要将共享变量 v 封装在对象的内部,而后控制并发访问的路径,这样就能有效防止对 Vector v 变量的滥用,从而导致并发问题。你可以参考下面的示例代码来加深理解。
4. 方法调用是先计算参数
不过,还有同学对07文中所举的例子有疑议,认为set(get()+1);这条语句是进入 set() 方法之后才执行 get() 方法,其实并不是这样的。方法的调用,是先计算参数,然后将参数压入调用栈之后才会执行方法体,方法调用的过程在11这篇文章中我们已经做了详细的介绍,你可以再次重温一下。
先计算参数这个事情也是容易被忽视的细节。例如,下面写日志的代码,如果日志级别设置为 INFO,虽然这行代码不会写日志,但是会计算"The var1:" + var1 + ", var2:" + var2的值,因为方法调用前会先计算参数。
更好地写法应该是下面这样,这种写法仅仅是讲参数压栈,而没有参数的计算。使用{}占位符是写日志的一个良好习惯。
5. InterruptedException 异常处理需小心
《 09 | Java 线程(上):Java 线程的生命周期》的思考题主要是希望你能够注意 InterruptedException 的处理方式。当你调用 Java 对象的 wait() 方法或者线程的 sleep() 方法时,需要捕获并处理 InterruptedException 异常,在思考题里面(如下所示),本意是通过 isInterrupted() 检查线程是否被中断了,如果中断了就退出 while 循环。当其他线程通过调用th.interrupt().来中断 th 线程时,会设置 th 线程的中断标志位,从而使th.isInterrupted()返回 true,这样就能退出 while 循环了。
这看上去一点问题没有,实际上却是几乎起不了作用。原因是这段代码在执行的时候,大部分时间都是阻塞在 sleep(100) 上,当其他线程通过调用th.interrupt().来中断 th 线程时,大概率地会触发 InterruptedException 异常,在触发 InterruptedException 异常的同时,JVM 会同时把线程的中断标志位清除,所以这个时候th.isInterrupted()返回的是 false。
正确的处理方式应该是捕获异常之后重新设置中断标志位,也就是下面这样:
6. 理论值 or 经验值
《10 | Java 线程(中):创建多少线程才是合适的?》的思考题是:经验值为“最佳线程 =2 * CPU 的核数 + 1”,是否合理?
从理论上来讲,这个经验值一定是靠不住的。但是经验值对于很多“I/O 耗时 / CPU 耗时”不太容易确定的系统来说,却是一个很好到初始值。
我们曾讲到最佳线程数最终还是靠压测来确定的,实际工作中大家面临的系统,“I/O 耗时 / CPU 耗时”往往都大于 1,所以基本上都是在这个初始值的基础上增加。增加的过程中,应关注线程数是如何影响吞吐量和延迟的。一般来讲,随着线程数的增加,吞吐量会增加,延迟也会缓慢增加;但是当线程数增加到一定程度,吞吐量就会开始下降,延迟会迅速增加。这个时候基本上就是线程能够设置的最大值了。
实际工作中,不同的 I/O 模型对最佳线程数的影响非常大,例如大名鼎鼎的 Nginx 用的是非阻塞 I/O,采用的是多进程单线程结构,Nginx 本来是一个 I/O 密集型系统,但是最佳进程数设置的却是 CPU 的核数,完全参考的是 CPU 密集型的算法。所以,理论我们还是要活学活用。
总结
这个模块,内容主要聚焦在并发编程相关的理论上,但是思考题则是聚焦在细节上,我们经常说细节决定成败,在并发编程领域尤其如此。理论主要用来给我们提供解决问题的思路和方法,但在具体实践的时候,还必须重点关注每一个细节,哪怕有一个细节没有处理好,都会导致并发问题。这方面推荐你认真阅读《Java 安全编码标准》这本书,如果你英文足够好,也可以参考这份文档。
最后总结一句,学好理论有思路,关注细节定成败。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
分享给需要的人,Ta购买本课程,你将得18元
生成海报并分享
赞 67
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
12 | 如何用面向对象思想写好并发程序?
下一篇
14 | Lock和Condition(上):隐藏在并发包中的管程
精选留言(75)
- binary2019-03-28这个专栏内容值得反复阅读!共 1 条评论134
- Jialin2019-03-28建议iamNigel同学去搜索下Integer String Boolean相关的知识,Integer会缓存-128~127这个范围内的数值,String对象同样会缓存字符串常量到字符串常量池,可供重复使用,所以不能用来用作锁对象,网上有相关的知识讲解和面试问题 老师讲解的非常不错,单看每一节,觉得自己已略一二,学完这节才发现要自己的知识点要串起来,整体了解并发
作者回复: 感谢帮忙回复!
共 2 条评论68 - linqw2019-03-30学完这模块,自己理下,老师帮忙看下哦 1、产生并发的原因:cpu、内存、磁盘速度的差异,在硬件和软件方面解决速度差异引发的并发问题,cpu缓存->可见性,线程切换->原子性,编译优化->重排序,引发并发问题的根源。 2、并发的解决:可见性解决方法->volatile、synchronized,原子性的解决方法->互斥锁,重排序->volatile,禁掉编译优化 3、解决并发原子性产生的问题:死锁,死锁产生的所有条件->①资源互斥②不能抢占③占有且等待④循环等待,死锁的解决办法->①按锁的顺序获取②增加锁的分配器。 4、宏观角度分析,以上都是从微观角度进行分析并发问题,宏观,即安全问题,性能问题,活跃性问题 5、本质看问题,管程 6、实际看问题,java生命周期,线程数的分配,线程的执行 7、以子之矛攻子之盾,面向对象解决并发问题, 属性final、私有、只有getter方法没有setter方法,属性的赋值,深复制再进行操作等等展开
作者回复: 很全面了
共 3 条评论65 - 小辉辉2019-05-17学了专栏之后,在项目里面写并发的BUG更有信心了59
- Demon.Lee2019-03-28这个课程99便宜了,建议涨价,一定要反复多看几遍
作者回复: 这个建议可以多提😃
共 8 条评论31 - 探索无止境2019-08-26对于Nginx为什么属于IO密集型的?我的理解是这样,这个也要看场景,Nginx作为反向代理服务器,那么它会通过负载均衡策略调用后端的服务器,而远程调用属于IO操作,所以此处Nginx作为IO密集型的操作。但因为它 采用的是非阻塞IO模型,所以工作的方式又类似于CPU密集型,所以设置的最佳线程数为CPU的核数。不知道这样的理解是否正确?请老师指正
作者回复: 正确,nginx里一般都是设置成worker进程和CPU一一对应的
16 - zhangtnty2019-03-28王老师好,在第11讲中,new出的对象放入堆,局部变量放入栈桢。那么new出的线程会放到哪里?麻烦老师这块能否展开讲一下,谢谢😊
作者回复: 线程也是个对象,对象的引用在栈里,对象在堆里
15 - 皮卡皮卡丘2019-03-28看下源码就知道了,Integer里有个内部类,会缓存一定范围的整数
作者回复: 感谢帮忙回复!
12 - 江湖夜雨2019-07-28redis是不是也是IO密集型,所以设置为单线程?
作者回复: 是的
共 3 条评论10 - 彭锐2019-03-28String s1 = "lock"; String s2 = "lock"; 这两个是一个对象,即重用了。代码上看起来是操作了一个,其实是操作了两个。
作者回复: 这个例子好
共 2 条评论8 - Carpoor奇2019-12-12老师,因为String ,Integer,Boolean有缓存之后,虽然我们代码定义了两个对象,但是实际上这两个引用都指向堆里的同个对象,(值相同的情况)所以选择其中一个对象加锁了,实际上另一个对象也被加锁了 如果两个引用指向的值不同就没有问题,当两个引用指向的值相同就有问题了 这样理解对吗?展开
作者回复: 对
共 2 条评论7 - 刘得淼2019-03-28“学好理论有思路,关注细节定成败。”通过学前几章,帮助项目组里解决个并发的bug。现学现卖。
作者回复: 看来学的很好😃
7 - 红衣闪闪亮晶晶2019-04-07老师,我有一点不明白,我看到其他大佬的评论去搜了关于integer的知识,我明白integer内部有缓存,比如超过127会重新新建一个类,这样的sync锁的就是不同的对象了,可是如果是-128 - 127之间,会重用缓存,那他们不就是同一个对象了吗,为什么还会锁不住呢?
作者回复: 如果100个人的项目都用这个缓存的对象做锁,还有人一直不释放,那整个系统都不了用了,锁也要隔离的
共 3 条评论6 - zws2019-03-29推荐 java 并发编程实战 加深理解。共 1 条评论6
- IamNigel2019-03-28Integer string Boolean的可重用没太明白,希望老师讲解下6
- maybe2020-09-231、产生并发问题的根源: a、cpu缓存导致可见性问题。cpu、内存、i/o设备速度差异,cpu增加了缓存来平衡速度差异,就是cpu会使用缓存,多核情况下就会产生可见性问题。 b、多线程切换导致原子性问题。cpu能保证的原子性操作是一条指令,而不是编程语言的一条语句。往往编程语言的一条指令包含多条cpu指令,这点往往产生误导性。 c、编译优化带来有序性问题。为了更好的使用cpu缓存,编译器会优化指令执行顺序,指令重排序导致了顺序性问题。 2、java引入内存模型解决并发问题(可见性、原子性、有序性),实际上就是按需禁用缓存和编译优化,具体就是synchronized、final、volatile,以及happens-before规则。 a、程序顺序性规则。按照程序顺序,happens-before后续的操作。 b、volatile规则。对volatile变量的写操作happens-before后续对这个volatile变量的读操作。 c、传递性规则。a happens-before b,b happens-before c,那么a happens-before c。 d、管程中锁的规则。一个锁的解锁操作happens-before后续这个锁的加锁操作。 e、线程start规则。A启动子线程B,B能看到启动前主线程的操作。 f、线程join规则。A线程调用B线程的join方法,B线程完成后,A线程能看到B线程的操作。 3、解决方法原子性问题,保证中间状态对外可见。使用互斥锁可以保证原子性。锁和受保护资源一定要对应,切不可出现用自家锁保护他家财产的可笑用法。解决可见性问题,使用volatile、synchronized都能保证可见性。volatile可以保证 4、a、死锁产生的4个条件:1、互斥,X、Y资源只能同时被一个线程占有。2、不可抢占,A线程不可强行抢夺B线程占有的资源。3、占有且等待。A线程占用资源X,再去获取Y资源的时候不释放X。4、循环等待。A线程占用资源X,等待资源Y,线程B占用资源Y,等待资源X。 b、破环2、3、4这三个条件中的任何一个,都可以防止死锁。对于条件3,可以一次性获取全部的资源,这样就不存在等待了。对于条件2,可以尝试如果再去获取资源而得不到的时候释放自身占有的资源。对于条件4,可以按照统一的资源顺序获取锁。 5、a、宏观看问题,并发问题主要解决三大问题,安全性(原子性、可见性、有序性)、活跃性问题(死锁、活锁、线程饥饿)、性能问题(使用锁过度导致串行范围过大)。 b、解决安全性问题:可以使用使用互斥锁。 c、解决活跃性问题:死锁:打破死锁的三个必要条件中的一个。活锁:可以让线程随机等待一个时间后再执行。线程饥饿:引发线程饥饿的问题可能是线程优先级比较低,如果cpu比较繁忙,那么低优先级的线程获得执行的机会很小。或者获取锁的线程执行时间过长。 d、解决性能问题:锁的细粒度尽量减少,减少串行度。展开4
- 放空2020-06-19看了老师的这篇,受益匪浅!立马键入王宝令搜索课程,发现这篇是独苗,有点小失望。希望老师之后发布更多课程!!!2
- 设计模式并发编程2020-06-15订阅了4个专栏, 就这个专栏讲得最好, 其它几个专栏的代码例子一行写的非常长, 要左右来回滑动阅读非常低效. 再就是文章写的逻辑非常绕, 讲得好乏味, 比看康德, 黑格尔 休谟的哲学书籍还难懂 . . .2
- 凌尘2019-08-13真的不错,让人受益匪浅~
作者回复: 😄
2 - 李湘河2019-03-28复习了一遍想问老师一个问题,我对java中synchronized理解是只能解决可见性和原子性问题,不能解决有续性问题,但是java中synchronized是管程模型的实现,而管程模型可以解决并发编程里的所有问题(同步和互斥),这个意思是也可以解决java内存模型中的有续性问题吗?不知道我的理解对不对,还请老师解答一下?
作者回复: 能解决有序性,会禁止重排的
共 10 条评论2