first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 以面试题为切入点有效提升你的Java内功
Java是一门历史悠久的编程语言可以毫无争议地说Java是最主流的编程语言之一。全球有1200万以上Java程序员以及海量的设备还有无所不能的Java生态圈。
我所知道的诸如阿里巴巴、京东、百度、腾讯、美团、去哪儿等互联网公司基本都是以Java为首要编程语言的。即使在最新的云计算领域Java仍然是AWS、Google App Engine等平台上使用最多的编程语言甚至是微软Azure云上Java也以微弱劣势排在前三位。所以在这些大公司的面试中基本都会以Java为切入点考评一个面试者的技术能力。
应聘初级、中级Java工程师通常只要求扎实的Java和计算机科学基础掌握主流开源框架的使用Java高级工程师或者技术专家则往往全面考察Java IO/NIO、并发、虚拟机等不仅仅是了解更要求对底层源代码层面的掌握并对分布式、安全、性能等领域能力有进一步的要求。
我在Oracle已经工作了近7年负责过北京Java核心类库、国际化、分发服务等技术团队的组建面试过从初级到非常资深的Java开发工程师。由于Java组工作任务的特点我非常注重面试者的计算机科学基础和编程语言的理解深度我甚至不要求面试者非要精通Java如果对C/C++等其他语言能够掌握得非常系统和深入,也是符合需求的。
工作多年以及在面试中,我经常能体会到,有些面试者确实是认真努力工作,但坦白说表现出的能力水平却不足以通过面试,通常是两方面原因:
“知其然不知其所以然”。做了多年技术,开发了很多业务应用,但似乎并未思考过种种技术选择背后的逻辑。坦白说,我并不放心把具有一定深度的任务交给他。更重要的是,我并不确定他未来技术能力的成长潜力有多大。团队所从事的是公司核心产品,工作于基础技术领域,我们不需要那些“差不多”或“还行”的代码,而是需要达到一定水准的高质量设计与实现。我相信很多其他技术团队的要求会更多、更高。
知识碎片化,不成系统。在面试中,面试者似乎无法完整、清晰地描述自己所开发的系统,或者使用的相关技术。平时可能埋头苦干,或者过于死磕某个实现细节,并没有抬头审视这些技术。比如,有的面试者,有一些并发编程经验,但对基本的并发类库掌握却并不扎实,似乎觉得在用的时候进行“面向搜索引擎的编程”就足够了。这种情况下,我没有信心这个面试者有高效解决复杂问题、设计复杂系统的能力。
前人已经掉过的坑,后来的同学就别再“前仆后继”了!
起初极客时间邀请我写《Java核心技术面试精讲》专栏我一开始心里是怀疑其形式和必要性的。经典的书籍一大堆呀网上也能搜到所谓的“面试宝典”呀为什么还需要我“指手画脚”
但随着深入交流,我逐渐被说服了。我发现很多面试者其实是很努力的,只是
很难甑别出各种技术的核心与要点,技术书籍这么庞杂,对于经验有限的同学,找到高效归纳自己知识体系的方法并不容易。
各种“宝典”更专注于问题,解答大多点到即止,甚至有些解答准确性都值得商榷,缺乏系统性的分析与举一反三的讲解。
我在极客时间推出这个专栏,就是为了让更多没有经验或者经验有限的开发者,在准备面试时:
少走弯路,利用有限的精力,能够更加高效地准备和学习。
提纲挈领在知识点讲解的同时为你梳理一个相对完整的Java开发技术能力图谱将基础夯实。
Java面试题目千奇百怪有的面试官甚至会以黑魔法一样的态度刨根问底JVM底层似乎不深挖JVM源代码、不谈谈计算机指令就是不爱学习这是仁者见仁智者见智的事儿。我会根据自己的经验围绕Java开发技术的方方面面精选出5大模块共36道题目给出典型的回答并层层深入剖析。
5大模块分为
Java基础我会围绕Java语言基本特性和机制由点带面让你构建牢固的Java技术功底。
Java进阶将围绕并发编程、Java虚拟机等领域展开助你攻坚大厂Java面试的核心阵地。
Java应用开发扩展从数据库编程、主流开源框架、分布式开发等帮你掌握Java开发的十八般兵器。
Java安全基础让你理解常见的应用安全问题和处理方法掌握如何写出符合大厂规范的安全代码。
Java性能基础你将掌握相关工具、方法论与基础实践。
这几年我从业务系统或产品开发切换到Java平台自身接触了更多Java领域的核心技术我相信我的分享能够提供一些独到的内容而不是简单的人云亦云。
时移世易很多大家耳熟能知的问题其实在现代Java里已经发生了根本性的改变。在技术领域即使你打算或已经转为技术管理等扎实的技术功底也是必须的。希望通过我的专栏不仅可以让你面试成功还能帮助你未来职业发展更进一步。
万丈高楼平地起愿我这个Java老兵能与你一道逐个击破大厂Java面试考点直击Java技术核心要点构建你的Java知识体系。

View File

@@ -0,0 +1,164 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 Exception和Error有什么区别
世界上存在永远不会出错的程序吗?也许这只会出现在程序员的梦中。随着编程语言和软件的诞生,异常情况就如影随形地纠缠着我们,只有正确处理好意外情况,才能保证程序的可靠性。
Java语言在设计之初就提供了相对完善的异常处理机制这也是Java得以大行其道的原因之一因为这种机制大大降低了编写和维护可靠程序的门槛。如今异常处理机制已经成为现代编程语言的标配。
今天我要问你的问题是请对比Exception和Error另外运行时异常与一般异常有什么区别
典型回答
Exception和Error都是继承了Throwable类在Java中只有Throwable类型的实例才可以被抛出throw或者捕获catch它是异常处理机制的基本组成类型。
Exception和Error体现了Java平台设计者对不同异常情况的分类。Exception是程序正常运行中可以预料的意外情况可能并且应该被捕获进行相应处理。
Error是指在正常情况下不大可能出现的情况绝大部分的Error都会导致程序比如JVM自身处于非正常的、不可恢复状态。既然是非正常情况所以不便于也不需要捕获常见的比如OutOfMemoryError之类都是Error的子类。
Exception又分为可检查checked异常和不检查unchecked异常可检查异常在源代码里必须显式地进行捕获处理这是编译期检查的一部分。前面我介绍的不可查的Error是Throwable不是Exception。
不检查异常就是所谓的运行时异常,类似 NullPointerException、ArrayIndexOutOfBoundsException之类通常是可以编码避免的逻辑错误具体根据需要来判断是否需要捕获并不会在编译期强制要求。
考点分析
分析Exception和Error的区别是从概念角度考察了Java处理机制。总的来说还处于理解的层面面试者只要阐述清楚就好了。
我们在日常编程中,如何处理好异常是比较考验功底的,我觉得需要掌握两个方面。
第一理解Throwable、Exception、Error的设计和分类。比如掌握那些应用最为广泛的子类以及如何自定义异常等。
很多面试官会进一步追问一些细节比如你了解哪些Error、Exception或者RuntimeException我画了一个简单的类图并列出来典型例子可以给你作为参考至少做到基本心里有数。
其中有些子类型最好重点理解一下比如NoClassDefFoundError和ClassNotFoundException有什么区别这也是个经典的入门题目。
第二理解Java语言中操作Throwable的元素和实践。掌握最基本的语法是必须的如try-catch-finally块throw、throws关键字等。与此同时也要懂得如何处理典型场景。
异常处理代码比较繁琐比如我们需要写很多千篇一律的捕获代码或者在finally里面做一些资源回收工作。随着Java语言的发展引入了一些更加便利的特性比如try-with-resources和multiple catch具体可以参考下面的代码段。在编译时期会自动生成相应的处理逻辑比如自动按照约定俗成close那些扩展了AutoCloseable或者Closeable的对象。
try (BufferedReader br = new BufferedReader(…);
BufferedWriter writer = new BufferedWriter(…)) {// Try-with-resources
// do something
catch ( IOException | XEception e) {// Multiple catch
// Handle it
}
知识扩展
前面谈的大多是概念性的东西,下面我来谈些实践中的选择,我会结合一些代码用例进行分析。
先开看第一个吧,下面的代码反映了异常处理中哪些不当之处?
try {
// 业务代码
// …
Thread.sleep(1000L);
} catch (Exception e) {
// Ignore it
}
这段代码虽然很短,但是已经违反了异常处理的两个基本原则。
第一尽量不要捕获类似Exception这样的通用异常而是应该捕获特定异常在这里是Thread.sleep()抛出的InterruptedException。
这是因为在日常的开发和合作中我们读代码的机会往往超过写代码软件工程是门协作的艺术所以我们有义务让自己的代码能够直观地体现出尽量多的信息而泛泛的Exception之类恰恰隐藏了我们的目的。另外我们也要保证程序不会捕获到我们不希望捕获的异常。比如你可能更希望RuntimeException被扩散出来而不是被捕获。
进一步讲除非深思熟虑了否则不要捕获Throwable或者Error这样很难保证我们能够正确程序处理OutOfMemoryError。
第二不要生吞swallow异常。这是异常处理中要特别注意的事情因为很可能会导致非常难以诊断的诡异情况。
生吞异常,往往是基于假设这段代码可能不会发生,或者感觉忽略异常是无所谓的,但是千万不要在产品代码做这种假设!
如果我们不把异常抛出来或者也没有输出到日志Logger之类程序可能在后续代码以不可控的方式结束。没人能够轻易判断究竟是哪里抛出了异常以及是什么原因产生了异常。
再来看看第二段代码
try {
// 业务代码
// …
} catch (IOException e) {
e.printStackTrace();
}
这段代码作为一段实验代码,它是没有任何问题的,但是在产品代码中,通常都不允许这样处理。你先思考一下这是为什么呢?
我们先来看看printStackTrace()的文档开头就是“Prints this throwable and its backtrace to the standard error stream”。问题就在这里在稍微复杂一点的生产系统中标准出错STERR不是个合适的输出选项因为你很难判断出到底输出到哪里去了。
尤其是对于分布式系统如果发生异常但是无法找到堆栈轨迹stacktrace这纯属是为诊断设置障碍。所以最好使用产品日志详细地输出到日志系统里。
我们接下来看下面的代码段体会一下Throw early, catch late原则。
public void readPreferences(String fileName){
//...perform operations...
InputStream in = new FileInputStream(fileName);
//...read the preferences file...
}
如果fileName是null那么程序就会抛出NullPointerException但是由于没有第一时间暴露出问题堆栈信息可能非常令人费解往往需要相对复杂的定位。这个NPE只是作为例子实际产品代码中可能是各种情况比如获取配置失败之类的。在发现问题的时候第一时间抛出能够更加清晰地反映问题。
我们可以修改一下让问题“throw early”对应的异常信息就非常直观了。
public void readPreferences(String filename) {
Objects. requireNonNull(filename);
//...perform other operations...
InputStream in = new FileInputStream(filename);
//...read the preferences file...
}
至于“catch late”其实是我们经常苦恼的问题捕获异常后需要怎么处理呢最差的处理方式就是我前面提到的“生吞异常”本质上其实是掩盖问题。如果实在不知道如何处理可以选择保留原有异常的cause信息直接再抛出或者构建新的异常抛出去。在更高层面因为有了清晰的业务逻辑往往会更清楚合适的处理方式是什么。
有的时候,我们会根据需要自定义异常,这个时候除了保证提供足够的信息,还有两点需要考虑:
是否需要定义成Checked Exception因为这种类型设计的初衷更是为了从异常情况恢复作为异常设计者我们往往有充足信息进行分类。
在保证诊断信息足够的同时也要考虑避免包含敏感信息因为那样可能导致潜在的安全问题。如果我们看Java的标准类库你可能注意到类似java.net.ConnectException出错信息是类似“ Connection refused (Connection refused)”而不包含具体的机器名、IP、端口等一个重要考量就是信息安全。类似的情况在日志中也有比如用户数据一般是不可以输出到日志里面的。
业界有一种争论甚至可以算是某种程度的共识Java语言的Checked Exception也许是个设计错误反对者列举了几点
Checked Exception的假设是我们捕获了异常然后恢复程序。但是其实我们大多数情况下根本就不可能恢复。Checked Exception的使用已经大大偏离了最初的设计目的。
Checked Exception不兼容functional编程如果你写过Lambda/Stream代码相信深有体会。
很多开源项目已经采纳了这种实践比如Spring、Hibernate等甚至反映在新的编程语言设计中比如Scala等。 如果有兴趣,你可以参考:
http://literatejava.com/exceptions/checked-exceptions-javas-biggest-mistake/。
当然很多人也觉得没有必要矫枉过正因为确实有一些异常比如和环境相关的IO、网络等其实是存在可恢复性的而且Java已经通过业界的海量实践证明了其构建高质量软件的能力。我就不再进一步解读了感兴趣的同学可以点击链接观看Bruce Eckel在2018年全球软件开发大会QCon的分享Failing at Failing: How and Why Weve Been Nonchalantly Moving Away From Exception Handling。
我们从性能角度来审视一下Java的异常处理机制这里有两个可能会相对昂贵的地方
try-catch代码段会产生额外的性能开销或者换个角度说它往往会影响JVM对代码进行优化所以建议仅捕获有必要的代码段尽量不要一个大的try包住整段的代码与此同时利用异常控制代码流程也不是一个好主意远比我们通常意义上的条件语句if/else、switch要低效。
Java每实例化一个Exception都会对当时的栈进行快照这是一个相对比较重的操作。如果发生的非常频繁这个开销可就不能被忽略了。
所以对于部分追求极致性能的底层类库有种方式是尝试创建不进行栈快照的Exception。这本身也存在争议因为这样做的假设在于我创建异常时知道未来是否需要堆栈。问题是实际上可能吗小范围或许可能但是在大规模项目中这么做可能不是个理智的选择。如果需要堆栈但又没有收集这些信息在复杂情况下尤其是类似微服务这种分布式系统这会大大增加诊断的难度。
当我们的服务出现反应变慢、吞吐量下降的时候检查发生最频繁的Exception也是一种思路。关于诊断后台变慢的问题我会在后面的Java性能基础模块中系统探讨。
今天我从一个常见的异常处理概念问题简单总结了Java异常处理的机制。并结合代码分析了一些普遍认可的最佳实践以及业界最新的一些异常使用共识。最后我分析了异常性能开销希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗可以思考一个问题对于异常处理编程不同的编程范式也会影响到异常处理策略比如现在非常火热的反应式编程Reactive Stream因为其本身是异步、基于事件机制的所以出现异常情况决不能简单抛出去另外由于代码堆栈不再是同步调用那种垂直的结构这里的异常处理和日志需要更加小心我们看到的往往是特定executor的堆栈而不是业务方法调用关系。对于这种情况你有什么好的办法吗
请你在留言区分享一下你的解决方案,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,180 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 谈谈final、finally、 finalize有什么不同
Java语言有很多看起来很相似但是用途却完全不同的语言要素这些内容往往容易成为面试官考察你知识掌握程度的切入点。
今天我要问你的是一个经典的Java基础题目谈谈final、finally、 finalize有什么不同
典型回答
final可以用来修饰类、方法、变量分别有不同的意义final修饰的class代表不可以继承扩展final的变量是不可以修改的而final的方法也是不可以重写的override
finally则是Java保证重点代码一定要被执行的一种机制。我们可以使用try-finally或者try-catch-finally来进行类似关闭JDBC连接、保证unlock锁等动作。
finalize是基础类java.lang.Object的一个方法它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。finalize机制现在已经不推荐使用并且在JDK 9开始被标记为deprecated。
考点分析
这是一个非常经典的Java基础问题我上面的回答主要是从语法和使用实践角度出发的其实还有很多方面可以深入探讨面试官还可以考察你对性能、并发、对象生命周期或垃圾收集基本过程等方面的理解。
推荐使用final关键字来明确表示我们代码的语义、逻辑意图这已经被证明在很多场景下是非常好的实践比如
我们可以将方法或者类声明为final这样就可以明确告知别人这些行为是不许修改的。
如果你关注过Java核心类库的定义或源码 有没有发现java.lang包下面的很多类相当一部分都被声明成为final class在第三方类库的一些基础类中同样如此这可以有效避免API使用者更改基础功能某种程度上这是保证平台安全的必要手段。
使用final修饰参数或者变量也可以清楚地避免意外赋值导致的编程错误甚至有人明确推荐将所有方法参数、本地变量、成员变量声明成final。
final变量产生了某种程度的不可变immutable的效果所以可以用于保护只读数据尤其是在并发编程中因为明确地不能再赋值final变量有利于减少额外的同步开销也可以省去一些防御性拷贝的必要。
final也许会有性能的好处很多文章或者书籍中都介绍了可在特定场景提高性能比如利用final可能有助于JVM将方法进行内联可以改善编译器进行条件编译的能力等等。坦白说很多类似的结论都是基于假设得出的比如现代高性能JVM如HotSpot判断内联未必依赖final的提示要相信JVM还是非常智能的。类似的final字段对性能的影响大部分情况下并没有考虑的必要。
从开发实践的角度我不想过度强调这一点这是和JVM的实现很相关的未经验证比较难以把握。我的建议是在日常开发中除非有特别考虑不然最好不要指望这种小技巧带来的所谓性能好处程序最好是体现它的语义目的。如果你确实对这方面有兴趣可以查阅相关资料我就不再赘述了不过千万别忘了验证一下。
对于finally明确知道怎么使用就足够了。需要关闭的连接等资源更推荐使用Java 7中添加的try-with-resources语句因为通常Java平台能够更好地处理异常情况编码量也要少很多何乐而不为呢。
另外我注意到有一些常被考到的finally问题也比较偏门至少需要了解一下。比如下面代码会输出什么
try {
// do something
System.exit(1);
} finally{
System.out.println(“Print from finally”);
}
上面finally里面的代码可不会被执行的哦这是一个特例。
对于finalize我们要明确它是不推荐使用的业界实践一再证明它不是个好的办法在Java 9中甚至明确将Object.finalize()标记为deprecated如果没有特别的原因不要实现finalize方法也不要指望利用它来进行资源回收。
为什么呢简单说你无法保证finalize什么时候执行执行的是否符合预期。使用不当会影响性能导致程序死锁、挂起等。
通常来说利用上面的提到的try-with-resources或者try-finally机制是非常好的回收资源的办法。如果确实需要额外处理可以考虑Java提供的Cleaner机制或者其他替代方法。接下来我来介绍更多设计考虑和实践细节。
知识扩展
注意final不是immutable
我在前面介绍了final在实践中的益处需要注意的是final并不等同于immutable比如下面这段代码
final List<String> strList = new ArrayList<>();
strList.add("Hello");
strList.add("world");
List<String> unmodifiableStrList = List.of("hello", "world");
unmodifiableStrList.add("again");
final只能约束strList这个引用不可以被赋值但是strList对象行为不被final影响添加元素等操作是完全正常的。如果我们真的希望对象本身是不可变的那么需要相应的类支持不可变的行为。在上面这个例子中List.of方法创建的本身就是不可变List最后那句add是会在运行时抛出异常的。
Immutable在很多场景是非常棒的选择某种意义上说Java语言目前并没有原生的不可变支持如果要实现immutable的类我们需要做到
将class自身声明为final这样别人就不能扩展来绕过限制了。
将所有成员变量定义为private和final并且不要实现setter方法。
通常构造对象时,成员变量使用深度拷贝来初始化,而不是直接赋值,这是一种防御措施,因为你无法确定输入对象不被其他人修改。
如果确实需要实现getter方法或者其他可能会返回内部状态的方法使用copy-on-write原则创建私有的copy。
这些原则是不是在并发编程实践中经常被提到?的确如此。
关于setter/getter方法很多人喜欢直接用IDE一次全部生成建议最好是你确定有需要时再实现。
finalize真的那么不堪
前面简单介绍了finalize是一种已经被业界证明了的非常不好的实践那么为什么会导致那些问题呢
finalize的执行是和垃圾收集关联在一起的一旦实现了非空的finalize方法就会导致相应对象回收呈现数量级上的变慢有人专门做过benchmark大概是40~50倍的下降。
因为finalize被设计成在对象被垃圾收集前调用这就意味着实现了finalize方法的对象是个“特殊公民”JVM要对它进行额外处理。finalize本质上成为了快速回收的阻碍者可能导致你的对象经过多个垃圾收集周期才能被回收。
有人也许会问我用System.runFinalization()告诉JVM积极一点是不是就可以了也许有点用但是问题在于这还是不可预测、不能保证的所以本质上还是不能指望。实践中因为finalize拖慢垃圾收集导致大量对象堆积也是一种典型的导致OOM的原因。
从另一个角度我们要确保回收资源就是因为资源都是有限的垃圾收集时间的不可预测可能会极大加剧资源占用。这意味着对于消耗非常高频的资源千万不要指望finalize去承担资源释放的主要职责最多让finalize作为最后的“守门员”况且它已经暴露了如此多的问题。这也是为什么我推荐资源用完即显式释放或者利用资源池来尽量重用。
finalize还会掩盖资源回收时的出错信息我们看下面一段JDK的源代码截取自java.lang.ref.Finalizer
private void runFinalizer(JavaLangAccess jla) {
// ... 省略部分代码
try {
Object finalizee = this.get();
if (finalizee != null && !(finalizee instanceof java.lang.Enum)) {
jla.invokeFinalize(finalizee);
// Clear stack slot containing this variable, to decrease
// the chances of false retention with a conservative GC
finalizee = null;
}
} catch (Throwable x) { }
super.clear();
}
结合我上期专栏介绍的异常处理实践,你认为这段代码会导致什么问题?
是的你没有看错这里的Throwable是被生吞了的也就意味着一旦出现异常或者出错你得不到任何有效信息。况且Java在finalize阶段也没有好的方式处理任何信息不然更加不可预测。
有什么机制可以替换finalize吗
Java平台目前在逐步使用java.lang.ref.Cleaner来替换掉原有的finalize实现。Cleaner的实现利用了幻象引用PhantomReference这是一种常见的所谓post-mortem清理机制。我会在后面的专栏系统介绍Java的各种引用利用幻象引用和引用队列我们可以保证对象被彻底销毁前做一些类似资源回收的工作比如关闭文件描述符操作系统有限的资源它比finalize更加轻量、更加可靠。
吸取了finalize里的教训每个Cleaner的操作都是独立的它有自己的运行线程所以可以避免意外死锁等问题。
实践中我们可以为自己的模块构建一个Cleaner然后实现相应的清理逻辑。下面是JDK自身提供的样例程序
public class CleaningExample implements AutoCloseable {
// A cleaner, preferably one shared within a library
private static final Cleaner cleaner = <cleaner>;
static class State implements Runnable {
State(...) {
// initialize State needed for cleaning action
}
public void run() {
// cleanup action accessing State, executed at most once
}
}
private final State;
private final Cleaner.Cleanable cleanable
public CleaningExample() {
this.state = new State(...);
this.cleanable = cleaner.register(this, state);
}
public void close() {
cleanable.clean();
}
}
注意从可预测性的角度来判断Cleaner或者幻象引用改善的程度仍然是有限的如果由于种种原因导致幻象引用堆积同样会出现问题。所以Cleaner适合作为一种最后的保证手段而不是完全依赖Cleaner进行资源回收不然我们就要再做一遍finalize的噩梦了。
我也注意到很多第三方库自己直接利用幻象引用定制资源收集比如广泛使用的MySQL JDBC driver之一的mysql-connector-j就利用了幻象引用机制。幻象引用也可以进行类似链条式依赖关系的动作比如进行总量控制的场景保证只有连接被关闭相应资源被回收连接池才能创建新的连接。
另外这种代码如果稍有不慎添加了对资源的强引用关系就会导致循环引用关系前面提到的MySQL JDBC就在特定模式下有这种问题导致内存泄漏。上面的示例代码中将State定义为static就是为了避免普通的内部类隐含着对外部对象的强引用因为那样会使外部对象无法进入幻象可达的状态。
今天我从语法角度分析了final、finally、finalize并从安全、性能、垃圾收集等方面逐步深入探讨了实践中的注意事项希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗也许你已经注意到了JDK自身使用的Cleaner机制仍然是有缺陷的你有什么更好的建议吗
请你在留言区写写你的建议,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,181 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 强引用、软引用、弱引用、幻象引用有什么区别?
在Java语言中除了原始数据类型的变量其他所有都是所谓的引用类型指向各种不同的对象理解引用对于掌握Java对象生命周期和JVM内部相关机制非常有帮助。
今天我要问你的问题是,强引用、软引用、弱引用、幻象引用有什么区别?具体使用场景是什么?
典型回答
不同的引用类型主要体现的是对象不同的可达性reachable状态和对垃圾收集的影响。
所谓强引用“Strong” Reference就是我们最常见的普通对象引用只要还有强引用指向一个对象就能表明对象还“活着”垃圾收集器不会碰这种对象。对于一个普通的对象如果没有其他的引用关系只要超过了引用的作用域或者显式地将相应引用赋值为null就是可以被垃圾收集的了当然具体回收时机还是要看垃圾收集策略。
软引用SoftReference是一种相对强引用弱化一些的引用可以让对象豁免一些垃圾收集只有当JVM认为内存不足时才会去试图回收软引用指向的对象。JVM会确保在抛出OutOfMemoryError之前清理软引用指向的对象。软引用通常用来实现内存敏感的缓存如果还有空闲内存就可以暂时保留缓存当内存不足时清理掉这样就保证了使用缓存的同时不会耗尽内存。
弱引用WeakReference并不能使对象豁免垃圾收集仅仅是提供一种访问在弱引用状态下对象的途径。这就可以用来构建一种没有特定约束的关系比如维护一种非强制性的映射关系如果试图获取时对象还在就使用它否则重现实例化。它同样是很多缓存实现的选择。
对于幻象引用有时候也翻译成虚引用你不能通过它访问对象。幻象引用仅仅是提供了一种确保对象被finalize以后做某些事情的机制比如通常用来做所谓的Post-Mortem清理机制我在专栏上一讲中介绍的Java平台自身Cleaner机制等也有人利用幻象引用监控对象的创建和销毁。
考点分析
这道面试题,属于既偏门又非常高频的一道题目。说它偏门,是因为在大多数应用开发中,很少直接操作各种不同引用,虽然我们使用的类库、框架可能利用了其机制。它被频繁问到,是因为这是一个综合性的题目,既考察了我们对基础概念的理解,也考察了对底层对象生命周期、垃圾收集机制等的掌握。
充分理解这些引用对于我们设计可靠的缓存等框架或者诊断应用OOM等问题会很有帮助。比如诊断MySQL connector-j驱动在特定模式下useCompression=true的内存泄漏问题就需要我们理解怎么排查幻象引用的堆积问题。
知识扩展
对象可达性状态流转分析
首先请你看下面流程图我这里简单总结了对象生命周期和不同可达性状态以及不同状态可能的改变关系可能未必100%严谨,来阐述下可达性的变化。
我来解释一下上图的具体状态这是Java定义的不同可达性级别reachability level具体如下
强可达Strongly Reachable就是当一个对象可以有一个或多个线程可以不通过各种引用访问到的情况。比如我们新创建一个对象那么创建它的线程对它就是强可达。
软可达Softly Reachable就是当我们只能通过软引用才能访问到对象的状态。
弱可达Weakly Reachable类似前面提到的就是无法通过强引用或者软引用访问只能通过弱引用访问时的状态。这是十分临近finalize状态的时机当弱引用被清除的时候就符合finalize的条件了。
幻象可达Phantom Reachable上面流程图已经很直观了就是没有强、软、弱引用关联并且finalize过了只有幻象引用指向这个对象的时候。
当然还有一个最后的状态就是不可达unreachable意味着对象可以被清除了。
判断对象可达性是JVM垃圾收集器决定如何处理对象的一部分考虑。
所有引用类型都是抽象类java.lang.ref.Reference的子类你可能注意到它提供了get()方法:
除了幻象引用因为get永远返回null如果对象还没有被销毁都可以通过get方法获取原有对象。这意味着利用软引用和弱引用我们可以将访问到的对象重新指向强引用也就是人为的改变了对象的可达性状态这也是为什么我在上面图里有些地方画了双向箭头。
所以,对于软引用、弱引用之类,垃圾收集器可能会存在二次确认的问题,以保证处于弱引用状态的对象,没有改变为强引用。
但是,你觉得这里有没有可能出现什么问题呢?
不错如果我们错误的保持了强引用比如赋值给了static变量那么对象可能就没有机会变回类似弱引用的可达性状态了就会产生内存泄漏。所以检查弱引用指向对象是否被垃圾收集也是诊断是否有特定内存泄漏的一个思路如果我们的框架使用到弱引用又怀疑有内存泄漏就可以从这个角度检查。
引用队列ReferenceQueue使用
谈到各种引用的编程就必然要提到引用队列。我们在创建各种引用并关联到相应对象时可以选择是否需要关联引用队列JVM会在特定时机将引用enqueue到队列里我们可以从队列里获取引用remove方法在这里实际是有获取的意思进行相关后续逻辑。尤其是幻象引用get方法只返回null如果再不指定引用队列基本就没有意义了。看看下面的示例代码。利用引用队列我们可以在对象处于相应状态时对于幻象引用就是前面说的被finalize了处于幻象可达状态执行后期处理逻辑。
Object counter = new Object();
ReferenceQueue refQueue = new ReferenceQueue<>();
PhantomReference<Object> p = new PhantomReference<>(counter, refQueue);
counter = null;
System.gc();
try {
// Remove是一个阻塞方法可以指定timeout或者选择一直阻塞
Reference<Object> ref = refQueue.remove(1000L);
if (ref != null) {
// do something
}
} catch (InterruptedException e) {
// Handle it
}
显式地影响软引用垃圾收集
前面泛泛提到了引用对垃圾收集的影响尤其是软引用到底JVM内部是怎么处理它的其实并不是非常明确。那么我们能不能使用什么方法来影响软引用的垃圾收集呢
答案是有的。软引用通常会在最后一次引用后还能保持一段时间默认值是根据堆剩余空间计算的以M bytes为单位。从Java 1.3.1开始,提供了-XX:SoftRefLRUPolicyMSPerMB参数我们可以以毫秒milliseconds为单位设置。比如下面这个示例就是设置为3秒3000毫秒
-XX:SoftRefLRUPolicyMSPerMB=3000
这个剩余空间其实会受不同JVM模式影响对于Client模式比如通常的Windows 32 bit JDK剩余空间是计算当前堆里空闲的大小所以更加倾向于回收而对于server模式JVM则是根据-Xmx指定的最大值来计算。
本质上这个行为还是个黑盒取决于JVM实现即使是上面提到的参数在新版的JDK上也未必有效另外Client模式的JDK已经逐步退出历史舞台。所以在我们应用时可以参考类似设置但不要过于依赖它。
诊断JVM引用情况
如果你怀疑应用存在引用或finalize导致的回收问题可以有很多工具或者选项可供选择比如HotSpot JVM自身便提供了明确的选项PrintReferenceGC去获取相关信息我指定了下面选项去使用JDK 8运行一个样例应用
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintReferenceGC
这是JDK 8使用ParrallelGC收集的垃圾收集日志各种引用数量非常清晰。
0.403: [GC (Allocation Failure) 0.871: [SoftReference, 0 refs, 0.0000393 secs]0.871: [WeakReference, 8 refs, 0.0000138 secs]0.871: [FinalReference, 4 refs, 0.0000094 secs]0.871: [PhantomReference, 0 refs, 0 refs, 0.0000085 secs]0.871: [JNI Weak Reference, 0.0000071 secs][PSYoungGen: 76272K->10720K(141824K)] 128286K->128422K(316928K), 0.4683919 secs] [Times: user=1.17 sys=0.03, real=0.47 secs]
注意JDK 9对JVM和垃圾收集日志进行了广泛的重构类似PrintGCTimeStamps和PrintReferenceGC已经不再存在我在专栏后面的垃圾收集主题里会更加系统的阐述。
Reachability Fence
除了我前面介绍的几种基本引用类型我们也可以通过底层API来达到强引用的效果这就是所谓的设置reachability fence。
为什么需要这种机制呢考虑一下这样的场景按照Java语言规范如果一个对象没有指向强引用就符合垃圾收集的标准有些时候对象本身并没有强引用但是也许它的部分属性还在被使用这样就导致诡异的问题所以我们需要一个方法在没有强引用情况下通知JVM对象是在被使用的。说起来有点绕我们来看看Java 9中提供的案例。
class Resource {
private static ExternalResource[] externalResourceArray = ...
int myIndex; Resource(...) {
myIndex = ...
externalResourceArray[myIndex] = ...;
...
}
protected void finalize() {
externalResourceArray[myIndex] = null;
...
}
public void action() {
try {
// 需要被保护的代码
int i = myIndex;
Resource.update(externalResourceArray[i]);
} finally {
// 调用reachbilityFence明确保障对象strongly reachable
Reference.reachabilityFence(this);
}
}
private static void update(ExternalResource ext) {
ext.status = ...;
}
}
方法action的执行依赖于对象的部分属性所以被特定保护了起来。否则如果我们在代码中像下面这样调用那么就可能会出现困扰因为没有强引用指向我们创建出来的Resource对象JVM对它进行finalize操作是完全合法的。
new Resource().action()
类似的书写结构,在异步编程中似乎是很普遍的,因为异步编程中往往不会用传统的“执行->返回->使用”的结构。
在Java 9之前实现类似功能相对比较繁琐有的时候需要采取一些比较隐晦的小技巧。幸好java.lang.ref.Reference给我们提供了新方法它是JEP 193: Variable Handles的一部分将Java平台底层的一些能力暴露出来
static void reachabilityFence(Object ref)
在JDK源码中reachabilityFence大多使用在Executors或者类似新的HTTP/2客户端代码中大部分都是异步调用的情况。编程中可以按照上面这个例子将需要reachability保障的代码段利用try-finally包围起来在finally里明确声明对象强可达。
今天我总结了Java语言提供的几种引用类型、相应可达状态以及对于JVM工作的意义并分析了引用队列使用的一些实际情况最后介绍了在新的编程模式下如何利用API去保障对象不被意外回收希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?给你留一道练习题,你能从自己的产品或者第三方类库中找到使用各种引用的案例吗?它们都试图解决什么问题?
请你在留言区写写你的答案,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享出去,或许你能帮到他。

View File

@@ -0,0 +1,180 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 String、StringBuffer、StringBuilder有什么区别
今天我会聊聊日常使用的字符串,别看它似乎很简单,但其实字符串几乎在所有编程语言里都是个特殊的存在,因为不管是数量还是体积,字符串都是大多数应用中的重要组成。
今天我要问你的问题是理解Java的字符串String、StringBuffer、StringBuilder有什么区别
典型回答
String是Java语言非常基础和重要的类提供了构造和管理字符串的各种基本逻辑。它是典型的Immutable类被声明成为final class所有属性也都是final的。也由于它的不可变性类似拼接、裁剪字符串等动作都会产生新的String对象。由于字符串操作的普遍性所以相关操作的效率往往对应用性能有明显影响。
StringBuffer是为解决上面提到拼接产生太多中间对象的问题而提供的一个类我们可以用append或者add方法把字符串添加到已有序列的末尾或者指定位置。StringBuffer本质是一个线程安全的可修改字符序列它保证了线程安全也随之带来了额外的性能开销所以除非有线程安全的需要不然还是推荐使用它的后继者也就是StringBuilder。
StringBuilder是Java 1.5中新增的在能力上和StringBuffer没有本质区别但是它去掉了线程安全的部分有效减小了开销是绝大部分情况下进行字符串拼接的首选。
考点分析
几乎所有的应用开发都离不开操作字符串理解字符串的设计和实现以及相关工具如拼接类的使用对写出高质量代码是非常有帮助的。关于这个问题我前面的回答是一个通常的概要性回答至少你要知道String是Immutable的字符串操作不当可能会产生大量临时字符串以及线程安全方面的区别。
如果继续深入,面试官可以从各种不同的角度考察,比如可以:
通过String和相关类考察基本的线程安全设计与实现各种基础编程实践。
考察JVM对象缓存机制的理解以及如何良好地使用。
考察JVM优化Java代码的一些技巧。
String相关类的演进比如Java 9中实现的巨大变化。
针对上面这几方面,我会在知识扩展部分与你详细聊聊。
知识扩展
字符串设计和实现考量
我在前面介绍过String是Immutable类的典型实现原生的保证了基础线程安全因为你无法对它内部数据进行任何修改这种便利甚至体现在拷贝构造函数中由于不可变Immutable对象在拷贝时不需要额外复制数据。
我们再来看看StringBuffer实现的一些细节它的线程安全是通过把各种修改数据的方法都加上synchronized关键字实现的非常直白。其实这种简单粗暴的实现方式非常适合我们常见的线程安全类实现不必纠结于synchronized性能之类的有人说“过早优化是万恶之源”考虑可靠性、正确性和代码可读性才是大多数应用开发最重要的因素。
为了实现修改字符序列的目的StringBuffer和StringBuilder底层都是利用可修改的charJDK 9以后是byte数组二者都继承了AbstractStringBuilder里面包含了基本操作区别仅在于最终的方法是否加了synchronized。
另外这个内部数组应该创建成多大的呢如果太小拼接的时候可能要重新创建足够大的数组如果太大又会浪费空间。目前的实现是构建时初始字符串长度加16这意味着如果没有构建对象时输入最初的字符串那么初始值就是16。我们如果确定拼接会发生非常多次而且大概是可预计的那么就可以指定合适的大小避免很多次扩容的开销。扩容会产生多重开销因为要抛弃原有数组创建新的可以简单认为是倍数数组还要进行arraycopy。
前面我讲的这些内容,在具体的代码书写中,应该如何选择呢?
在没有线程安全问题的情况下全部拼接操作是应该都用StringBuilder实现吗毕竟这样书写的代码还是要多敲很多字的可读性也不理想下面的对比非常明显。
String strByBuilder = new
StringBuilder().append("aa").append("bb").append("cc").append
("dd").toString();
String strByConcat = "aa" + "bb" + "cc" + "dd";
其实在通常情况下没有必要过于担心要相信Java还是非常智能的。
我们来做个实验把下面一段代码利用不同版本的JDK编译然后再反编译例如
public class StringConcat {
public static String concat(String str) {
return str + “aa” + “bb”;
}
}
先编译再反编译比如使用不同版本的JDK
${JAVA_HOME}/bin/javac StringConcat.java
${JAVA_HOME}/bin/javap -v StringConcat.class
JDK 8的输出片段是
0: new #2 // class java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
7: aload_0
8: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
11: ldc #5 // String aa
13: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
16: ldc #6 // String bb
18: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
而在JDK 9中反编译的结果就会有点特别了片段是
// concat method
1: invokedynamic #2, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
// ...
// 实际是利用了MethodHandle,统一了入口
0: #15 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
你可以看到非静态的拼接逻辑在JDK 8中会自动被javac转换为StringBuilder操作而在JDK 9里面则是体现了思路的变化。Java 9利用InvokeDynamic将字符串拼接的优化与javac生成的字节码解耦假设未来JVM增强相关运行时实现将不需要依赖javac的任何修改。
在日常编程中,保证程序的可读性、可维护性,往往比所谓的最优性能更重要,你可以根据实际需求酌情选择具体的编码方式。
字符串缓存
我们粗略统计过把常见应用进行堆转储Dump Heap然后分析对象组成会发现平均25%的对象是字符串,并且其中约半数是重复的。如果能避免创建重复字符串,可以有效降低内存消耗和对象创建开销。
String在Java 6以后提供了intern()方法目的是提示JVM把相应字符串缓存起来以备重复使用。在我们创建字符串对象并调用intern()方法的时候如果已经有缓存的字符串就会返回缓存里的实例否则将其缓存起来。一般来说JVM会将所有的类似“abc”这样的文本字符串或者字符串常量之类缓存起来。
看起来很不错是吧但实际情况估计会让你大跌眼镜。一般使用Java 6这种历史版本并不推荐大量使用intern为什么呢魔鬼存在于细节中被缓存的字符串是存在所谓PermGen里的也就是臭名昭著的“永久代”这个空间是很有限的也基本不会被FullGC之外的垃圾收集照顾到。所以如果使用不当OOM就会光顾。
在后续版本中这个缓存被放置在堆中这样就极大避免了永久代占满的问题甚至永久代在JDK 8中被MetaSpace元数据区替代了。而且默认缓存大小也在不断地扩大中从最初的1009到7u40以后被修改为60013。你可以使用下面的参数直接打印具体数字可以拿自己的JDK立刻试验一下。
-XX:+PrintStringTableStatistics
你也可以使用下面的JVM参数手动调整大小但是绝大部分情况下并不需要调整除非你确定它的大小已经影响了操作效率。
-XX:StringTableSize=N
Intern是一种显式地排重机制但是它也有一定的副作用因为需要开发者写代码时明确调用一是不方便每一个都显式调用是非常麻烦的另外就是我们很难保证效率应用开发阶段很难清楚地预计字符串的重复情况有人认为这是一种污染代码的实践。
幸好在Oracle JDK 8u20之后推出了一个新的特性也就是G1 GC下的字符串排重。它是通过将相同数据的字符串指向同一份数据来做到的是JVM底层的改变并不需要Java类库做什么修改。
注意这个功能目前是默认关闭的你需要使用下面参数开启并且记得指定使用G1 GC
-XX:+UseStringDeduplication
前面说到的几个方面只是Java底层对字符串各种优化的一角在运行时字符串的一些基础操作会直接利用JVM内部的Intrinsic机制往往运行的就是特殊优化的本地代码而根本就不是Java代码生成的字节码。Intrinsic可以简单理解为是一种利用native方式hard-coded的逻辑算是一种特别的内联很多优化还是需要直接使用特定的CPU指令具体可以看相关源码搜索“string”以查找相关Intrinsic定义。当然你也可以在启动实验应用时使用下面参数了解intrinsic发生的状态。
-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
//样例输出片段
180 3 3 java.lang.String::charAt (25 bytes)
@ 1 java.lang.String::isLatin1 (19 bytes)
...
@ 7 java.lang.StringUTF16::getChar (60 bytes) intrinsic
可以看出仅仅是字符串一个实现就需要Java平台工程师和科学家付出如此大且默默无闻的努力我们得到的很多便利都是来源于此。
我会在专栏后面的JVM和性能等主题详细介绍JVM内部优化的一些方法如果你有兴趣可以再深入学习。即使你不做JVM开发或者暂时还没有使用到特别的性能优化这些知识也能帮助你增加技术深度。
String自身的演化
如果你仔细观察过Java的字符串在历史版本中它是使用char数组来存数据的这样非常直接。但是Java中的char是两个bytes大小拉丁语系语言的字符根本就不需要太宽的char这样无区别的实现就造成了一定的浪费。密度是编程语言平台永恒的话题因为归根结底绝大部分任务是要来操作数据的。
其实在Java 6的时候Oracle JDK就提供了压缩字符串的特性但是这个特性的实现并不是开源的而且在实践中也暴露出了一些问题所以在最新的JDK版本中已经将它移除了。
在Java 9中我们引入了Compact Strings的设计对字符串进行了大刀阔斧的改进。将数据存储方式从char数组改变为一个byte数组加上一个标识编码的所谓coder并且将相关字符串操作类都进行了修改。另外所有相关的Intrinsic之类也都进行了重写以保证没有任何性能损失。
虽然底层实现发生了这么大的改变但是Java字符串的行为并没有任何大的变化所以这个特性对于绝大部分应用来说是透明的绝大部分情况不需要修改已有代码。
当然在极端情况下字符串也出现了一些能力退化比如最大字符串的大小。你可以思考下原来char数组的实现字符串的最大长度就是数组本身的长度限制但是替换成byte数组同样数组长度下存储能力是退化了一倍的还好这是存在于理论中的极限还没有发现现实应用受此影响。
在通用的性能测试和产品实验中,我们能非常明显地看到紧凑字符串带来的优势,即更小的内存占用、更快的操作速度。
今天我从String、StringBuffer和StringBuilder的主要设计和实现特点开始分析了字符串缓存的intern机制、非代码侵入性的虚拟机层面排重、Java 9中紧凑字符的改进并且初步接触了JVM的底层优化机制intrinsic。从实践的角度不管是Compact Strings还是底层intrinsic优化都说明了使用Java基础类库的优势它们往往能够得到最大程度、最高质量的优化而且只要升级JDK版本就能零成本地享受这些益处。
一课一练
关于今天我们讨论的题目你做到心中有数了吗限于篇幅有限还有很多字符相关的问题没有来得及讨论比如编码相关的问题。可以思考一下很多字符串操作比如getBytes()/String(byte[] bytes)等都是隐含着使用平台默认编码,这是一种好的实践吗?是否有利于避免乱码?
请你在留言区写写你对这个问题的思考,或者分享一下你在操作字符串时掉过的坑,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,166 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 动态代理是基于什么原理?
编程语言通常有各种不同的分类角度,动态类型和静态类型就是其中一种分类角度,简单区分就是语言类型信息是在运行时检查,还是编译期检查。
与其近似的还有一个对比,就是所谓强类型和弱类型,就是不同类型变量赋值时,是否需要显式地(强制)进行类型转换。
那么如何分类Java语言呢通常认为Java是静态的强类型语言但是因为提供了类似反射等机制也具备了部分动态类型语言的能力。
言归正传今天我要问你的问题是谈谈Java反射机制动态代理是基于什么原理
典型回答
反射机制是Java语言提供的一种基础功能赋予程序在运行时自省introspect官方用语的能力。通过反射我们可以直接操作类或者对象比如获取某个对象的类定义获取类声明的属性和方法调用方法或者构造对象甚至可以运行时修改类定义。
动态代理是一种方便运行时动态构建代理、动态处理代理方法调用的机制很多场景都是利用类似机制做到的比如用来包装RPC调用、面向切面的编程AOP
实现动态代理的方式很多比如JDK自身提供的动态代理就是主要利用了上面提到的反射机制。还有其他的实现方式比如利用传说中更高性能的字节码操作机制类似ASM、cglib基于ASM、Javassist等。
考点分析
这个题目给我的第一印象是稍微有点诱导的嫌疑可能会下意识地以为动态代理就是利用反射机制实现的这么说也不算错但稍微有些不全面。功能才是目的实现的方法有很多。总的来说这道题目考察的是Java语言的另外一种基础机制 反射它就像是一种魔法引入运行时自省能力赋予了Java语言令人意外的活力通过运行时操作元数据或对象Java可以灵活地操作运行时才能确定的信息。而动态代理则是延伸出来的一种广泛应用于产品开发中的技术很多繁琐的重复编程都可以被动态代理机制优雅地解决。
从考察知识点的角度,这道题涉及的知识点比较庞杂,所以面试官能够扩展或者深挖的内容非常多,比如:
考察你对反射机制的了解和掌握程度。
动态代理解决了什么问题,在你业务系统中的应用场景是什么?
JDK动态代理在设计和实现上与cglib等方式有什么不同进而如何取舍
这些考点似乎不是短短一篇文章能够囊括的,我会在知识扩展部分尽量梳理一下。
知识扩展
反射机制及其演进
对于Java语言的反射机制本身如果你去看一下java.lang或java.lang.reflect包下的相关抽象就会有一个很直观的印象了。Class、Field、Method、Constructor等这些完全就是我们去操作类和对象的元数据对应。反射各种典型用例的编程相信有太多文章或书籍进行过详细的介绍我就不再赘述了至少你需要掌握基本场景编程这里是官方提供的参考文档https://docs.oracle.com/javase/tutorial/reflect/index.html 。
关于反射有一点我需要特意提一下就是反射提供的AccessibleObject.setAccessible(boolean flag)。它的子类也大都重写了这个方法这里的所谓accessible可以理解成修饰成员的public、protected、private这意味着我们可以在运行时修改成员访问限制
setAccessible的应用场景非常普遍遍布我们的日常开发、测试、依赖注入等各种框架中。比如在O/R Mapping框架中我们为一个Java实体对象运行时自动生成setter、getter的逻辑这是加载或者持久化数据非常必要的框架通常可以利用反射做这个事情而不需要开发者手动写类似的重复代码。
另一个典型场景就是绕过API访问控制。我们日常开发时可能被迫要调用内部API去做些事情比如自定义的高性能NIO框架需要显式地释放DirectBuffer使用反射绕开限制是一种常见办法。
但是在Java 9以后这个方法的使用可能会存在一些争议因为Jigsaw项目新增的模块化系统出于强封装性的考虑对反射访问进行了限制。Jigsaw引入了所谓Open的概念只有当被反射操作的模块和指定的包对反射调用者模块Open才能使用setAccessible否则被认为是不合法illegal操作。如果我们的实体类是定义在模块里面我们需要在模块描述符中明确声明
module MyEntities {
// Open for reflection
opens com.mycorp to java.persistence;
}
因为反射机制使用广泛根据社区讨论目前Java 9仍然保留了兼容Java 8的行为但是很有可能在未来版本完全启用前面提到的针对setAccessible的限制即只有当被反射操作的模块和指定的包对反射调用者模块Open才能使用setAccessible我们可以使用下面参数显式设置。
--illegal-access={ permit | warn | deny }
动态代理
前面的问题问到了动态代理,我们一起看看,它到底是解决什么问题?
首先它是一个代理机制。如果熟悉设计模式中的代理模式我们会知道代理可以看作是对调用目标的一个包装这样我们对目标代码的调用不是直接发生的而是通过代理完成。其实很多动态代理场景我认为也可以看作是装饰器Decorator模式的应用我会在后面的专栏设计模式主题予以补充。
通过代理可以让调用者与实现者之间解耦。比如进行RPC调用框架内部的寻址、序列化、反序列化等对于调用者往往是没有太大意义的通过代理可以提供更加友善的界面。
代理的发展经历了静态到动态的过程源于静态代理引入的额外工作。类似早期的RMI之类古董技术还需要rmic之类工具生成静态stub等各种文件增加了很多繁琐的准备工作而这又和我们的业务逻辑没有关系。利用动态代理机制相应的stub等类可以在运行时生成对应的调用操作也是动态完成极大地提高了我们的生产力。改进后的RMI已经不再需要手动去准备这些了虽然它仍然是相对古老落后的技术未来也许会逐步被移除。
这么说可能不够直观我们可以看JDK动态代理的一个简单例子。下面只是加了一句print在生产系统中我们可以轻松扩展类似逻辑进行诊断、限流等。
public class MyDynamicProxy {
public static void main (String[] args) {
HelloImpl hello = new HelloImpl();
MyInvocationHandler handler = new MyInvocationHandler(hello);
// 构造代码实例
Hello proxyHello = (Hello) Proxy.newProxyInstance(HelloImpl.class.getClassLoader(), HelloImpl.class.getInterfaces(), handler);
// 调用代理方法
proxyHello.sayHello();
}
}
interface Hello {
void sayHello();
}
class HelloImpl implements Hello {
@Override
public void sayHello() {
System.out.println("Hello World");
}
}
class MyInvocationHandler implements InvocationHandler {
private Object target;
public MyInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
System.out.println("Invoking sayHello");
Object result = method.invoke(target, args);
return result;
}
}
上面的JDK Proxy例子非常简单地实现了动态代理的构建和代理操作。首先实现对应的InvocationHandler然后以接口Hello为纽带为被调用目标构建代理对象进而应用程序就可以使用代理对象间接运行调用目标的逻辑代理为应用插入额外逻辑这里是println提供了便利的入口。
从API设计和实现的角度这种实现仍然有局限性因为它是以接口为中心的相当于添加了一种对于被调用者没有太大意义的限制。我们实例化的是Proxy对象而不是真正的被调用类型这在实践中还是可能带来各种不便和能力退化。
如果被调用者没有实现接口而我们还是希望利用动态代理机制那么可以考虑其他方式。我们知道Spring AOP支持两种模式的动态代理JDK Proxy或者cglib如果我们选择cglib方式你会发现对接口的依赖被克服了。
cglib动态代理采取的是创建目标类的子类的方式因为是子类化我们可以达到近似使用被调用者本身的效果。在Spring编程中框架通常会处理这种情况当然我们也可以显式指定。关于类似方案的实现细节我就不再详细讨论了。
那我们在开发中怎样选择呢?我来简单对比下两种方式各自优势。
JDK Proxy的优势
最小化依赖关系减少依赖意味着简化开发和维护JDK本身的支持可能比cglib更加可靠。
平滑进行JDK版本升级而字节码类库通常需要进行更新以保证在新版Java上能够使用。
代码实现简单。
基于类似cglib框架的优势
有的时候调用目标可能不便实现额外接口从某种角度看限定调用者实现接口是有些侵入性的实践类似cglib动态代理就没有这种限制。
只操作我们关心的类,而不必为其他相关类增加工作量。
高性能。
另外从性能角度我想补充几句。记得有人曾经得出结论说JDK Proxy比cglib或者Javassist慢几十倍。坦白说不去争论具体的benchmark细节在主流JDK版本中JDK Proxy在典型场景可以提供对等的性能水平数量级的差距基本上不是广泛存在的。而且反射机制性能在现代JDK中自身已经得到了极大的改进和优化同时JDK很多功能也不完全是反射同样使用了ASM进行字节码操作。
我们在选型中,性能未必是唯一考量,可靠性、可维护性、编程工作量等往往是更主要的考虑因素,毕竟标准类库和反射编程的门槛要低得多,代码量也是更加可控的,如果我们比较下不同开源项目在动态代理开发上的投入,也能看到这一点。
动态代理应用非常广泛虽然最初多是因为RPC等使用进入我们视线但是动态代理的使用场景远远不仅如此它完美符合Spring AOP等切面编程。我在后面的专栏还会进一步详细分析AOP的目的和能力。简单来说它可以看作是对OOP的一个补充因为OOP对于跨越不同对象或类的分散、纠缠逻辑表现力不够比如在不同模块的特定阶段做一些事情类似日志、用户鉴权、全局性异常处理、性能监控甚至事务处理等你可以参考下面这张图。
AOP通过动态代理机制可以让开发者从这些繁琐事项中抽身出来大幅度提高了代码的抽象程度和复用度。从逻辑上来说我们在软件设计和实现中的类似代理如Facade、Observer等很多设计目的都可以通过动态代理优雅地实现。
今天我简要回顾了反射机制谈了反射在Java语言演进中正在发生的变化并且进一步探讨了动态代理机制和相关的切面编程分析了其解决的问题并探讨了生产实践中的选择考量。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,你在工作中哪些场景使用到了动态代理?相应选择了什么实现技术?选择的依据是什么?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,193 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 int和Integer有什么区别
Java虽然号称是面向对象的语言但是原始数据类型仍然是重要的组成元素所以在面试中经常考察原始数据类型和包装类等Java语言特性。
今天我要问你的问题是int和Integer有什么区别谈谈Integer的值缓存范围。
典型回答
int是我们常说的整形数字是Java的8个原始数据类型Primitive Typesboolean、byte 、short、char、int、float、double、long之一。Java语言虽然号称一切都是对象但原始数据类型是例外。
Integer是int对应的包装类它有一个int类型的字段存储数据并且提供了基本操作比如数学运算、int和字符串之间转换等。在Java 5中引入了自动装箱和自动拆箱功能boxing/unboxingJava可以根据上下文自动进行转换极大地简化了相关编程。
关于Integer的值缓存这涉及Java 5中另一个改进。构建Integer对象的传统方式是直接调用构造器直接new一个对象。但是根据实践我们发现大部分数据操作都是集中在有限的、较小的数值范围因而在Java 5中新增了静态工厂方法valueOf在调用它的时候会利用一个缓存机制带来了明显的性能改进。按照Javadoc这个值默认缓存是-128到127之间。
考点分析
今天这个问题涵盖了Java里的两个基础要素原始数据类型、包装类。谈到这里就可以非常自然地扩展到自动装箱、自动拆箱机制进而考察封装类的一些设计和实践。坦白说理解基本原理和用法已经足够日常工作需求了但是要落实到具体场景还是有很多问题需要仔细思考才能确定。
面试官可以结合其他方面,来考察面试者的掌握程度和思考逻辑,比如:
我在专栏第1讲中介绍的Java使用的不同阶段编译阶段、运行时自动装箱/自动拆箱是发生在什么阶段?
我在前面提到使用静态工厂方法valueOf会使用到缓存机制那么自动装箱的时候缓存机制起作用吗
为什么我们需要原始数据类型Java的对象似乎也很高效应用中具体会产生哪些差异
阅读过Integer源码吗分析下类或某些方法的设计要点。
似乎有太多内容可以探讨,我们一起来分析一下。
知识扩展
理解自动装箱、拆箱
自动装箱实际上算是一种语法糖。什么是语法糖可以简单理解为Java平台为我们自动进行了一些转换保证不同的写法在运行时等价它们发生在编译阶段也就是生成的字节码是一致的。
像前面提到的整数javac替我们自动把装箱转换为Integer.valueOf()把拆箱替换为Integer.intValue()这似乎这也顺道回答了另一个问题既然调用的是Integer.valueOf自然能够得到缓存的好处啊。
如何程序化的验证上面的结论呢?
你可以写一段简单的程序包含下面两句代码,然后反编译一下。当然,这是一种从表现倒推的方法,大多数情况下,我们还是直接参考规范文档会更加可靠,毕竟软件承诺的是遵循规范,而不是保持当前行为。
Integer integer = 1;
int unboxing = integer ++;
反编译输出:
1: invokestatic #2 // Method
java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
8: invokevirtual #3 // Method
java/lang/Integer.intValue:()I
这种缓存机制并不是只有Integer才有同样存在于其他的一些包装类比如
Boolean缓存了true/false对应实例确切说只会返回两个常量实例Boolean.TRUE/FALSE。
Short同样是缓存了-128到127之间的数值。
Byte数值有限所以全部都被缓存。
Character缓存范围\u0000\u007F
自动装箱/自动拆箱似乎很酷,在编程实践中,有什么需要注意的吗?
原则上建议避免无意中的装箱、拆箱行为尤其是在性能敏感的场合创建10万个Java对象和10万个整数的开销可不是一个数量级的不管是内存使用还是处理速度光是对象头的空间占用就已经是数量级的差距了。
我们其实可以把这个观点扩展开使用原始数据类型、数组甚至本地代码实现等在性能极度敏感的场景往往具有比较大的优势用其替换掉包装类、动态数组如ArrayList等可以作为性能优化的备选项。一些追求极致性能的产品或者类库会极力避免创建过多对象。当然在大多数产品代码里并没有必要这么做还是以开发效率优先。以我们经常会使用到的计数器实现为例下面是一个常见的线程安全计数器实现。
class Counter {
private final AtomicLong counter = new AtomicLong();
public void increase() {
counter.incrementAndGet();
}
}
如果利用原始数据类型,可以将其修改为
class CompactCounter {
private volatile long counter;
private static final AtomicLongFieldUpdater<CompactCounter> updater = AtomicLongFieldUpdater.newUpdater(CompactCounter.class, "counter");
public void increase() {
updater.incrementAndGet(this);
}
}
源码分析
考察是否阅读过、是否理解JDK源代码可能是部分面试官的关注点这并不完全是一种苛刻要求阅读并实践高质量代码也是程序员成长的必经之路下面我来分析下Integer的源码。
整体看一下Integer的职责它主要包括各种基础的常量比如最大值、最小值、位数等前面提到的各种静态工厂方法valueOf()获取环境变量数值的方法各种转换方法比如转换为不同进制的字符串如8进制或者反过来的解析方法等。我们进一步来看一些有意思的地方。
首先继续深挖缓存Integer的缓存范围虽然默认是-128到127但是在特别的应用场景比如我们明确知道应用会频繁使用更大的数值这时候应该怎么办呢
缓存上限值实际是可以根据需要调整的JVM提供了参数设置
-XX:AutoBoxCacheMax=N
这些实现都体现在java.lang.Integer源码之中并实现在IntegerCache的静态初始化块里。
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue = VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
...
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
...
}
第二我们在分析字符串的设计实现时提到过字符串是不可变的保证了基本的信息安全和并发编程中的线程安全。如果你去看包装类里存储数值的成员变量“value”你会发现不管是Integer还Boolean等都被声明为“private final”所以它们同样是不可变类型
这种设计是可以理解的或者说是必须的选择。想象一下这个应用场景比如Integer提供了getInteger()方法用于方便地读取系统属性我们可以用属性来设置服务器某个服务的端口如果我可以轻易地把获取到的Integer对象改变为其他数值这会带来产品可靠性方面的严重问题。
第三Integer等包装类定义了类似SIZE或者BYTES这样的常量这反映了什么样的设计考虑呢如果你使用过其他语言比如C、C++类似整数的位数其实是不确定的可能在不同的平台比如32位或者64位平台存在非常大的不同。那么在32位JDK或者64位JDK里数据位数会有不同吗或者说这个问题可以扩展为我使用32位JDK开发编译的程序运行在64位JDK上需要做什么特别的移植工作吗
其实这种移植对于Java来说相对要简单些因为原始数据类型是不存在差异的这些明确定义在Java语言规范里面不管是32位还是64位环境开发者无需担心数据的位数差异。
对于应用移植虽然存在一些底层实现的差异比如64位HotSpot JVM里的对象要比32位HotSpot JVM大具体区别取决于不同JVM实现的选择但是总体来说并没有行为差异应用移植还是可以做到宣称的“一次书写到处执行”应用开发者更多需要考虑的是容量、能力等方面的差异。
原始类型线程安全
前面提到了线程安全设计,你有没有想过,原始数据类型操作是不是线程安全的呢?
这里可能存在着不同层面的问题:
原始数据类型的变量显然要使用并发相关手段才能保证线程安全这些我会在专栏后面的并发主题详细介绍。如果有线程安全的计算需要建议考虑使用类似AtomicInteger、AtomicLong这样的线程安全类。
特别的是部分比较宽的数据类型比如float、double甚至不能保证更新操作的原子性可能出现程序读取到只更新了一半数据位的数值
Java原始数据类型和引用类型局限性
前面我谈了非常多的技术细节最后再从Java平台发展的角度来看看原始数据类型、对象的局限性和演进。
对于Java应用开发者设计复杂而灵活的类型系统似乎已经习以为常了。但是坦白说毕竟这种类型系统的设计是源于很多年前的技术决定现在已经逐渐暴露出了一些副作用例如
原始数据类型和Java泛型并不能配合使用
这是因为Java的泛型某种程度上可以算作伪泛型它完全是一种编译期的技巧Java编译期会自动将类型转换为对应的特定类型这就决定了使用泛型必须保证相应类型可以转换为Object。
无法高效地表达数据也不便于表达复杂的数据结构比如vector和tuple
我们知道Java的对象都是引用类型如果是一个原始数据类型数组它在内存里是一段连续的内存而对象数组则不然数据存储的是引用对象往往是分散地存储在堆的不同位置。这种设计虽然带来了极大灵活性但是也导致了数据操作的低效尤其是无法充分利用现代CPU缓存机制。
Java为对象内建了各种多态、线程安全等方面的支持但这不是所有场合的需求尤其是数据处理重要性日益提高更加高密度的值类型是非常现实的需求。
针对这些方面的增强目前正在OpenJDK领域紧锣密鼓地进行开发有兴趣的话你可以关注相关工程http://openjdk.java.net/projects/valhalla/ 。
今天,我梳理了原始数据类型及其包装类,从源码级别分析了缓存机制等设计和实现细节,并且针对构建极致性能的场景,分析了一些可以借鉴的实践。
一课一练
关于今天我们讨论的题目你做到心中有数了吗留一道思考题给你前面提到了从空间角度Java对象要比原始数据类型开销大的多。你知道对象的内存结构是什么样的吗比如对象头的结构。如何计算或者获取某个Java对象的大小?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,152 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 对比Vector、ArrayList、LinkedList有何区别
我们在日常的工作中能够高效地管理和操作数据是非常重要的。由于每个编程语言支持的数据结构不尽相同比如我最早学习的C语言需要自己实现很多基础数据结构管理和操作会比较麻烦。相比之下Java则要方便的多针对通用场景的需求Java提供了强大的集合框架大大提高了开发者的生产力。
今天我要问你的是有关集合框架方面的问题对比Vector、ArrayList、LinkedList有何区别
典型回答
这三者都是实现集合框架中的List也就是所谓的有序集合因此具体功能也比较近似比如都提供按照位置进行定位、添加或者删除的操作都提供迭代器以遍历其内容等。但因为具体的设计区别在行为、性能、线程安全等方面表现又有很大不同。
Vector是Java早期提供的线程安全的动态数组如果不需要线程安全并不建议选择毕竟同步是有额外开销的。Vector内部是使用对象数组来保存数据可以根据需要自动的增加容量当数组已满时会创建新的数组并拷贝原有数组数据。
ArrayList是应用更加广泛的动态数组实现它本身不是线程安全的所以性能要好很多。与Vector近似ArrayList也是可以根据需要调整容量不过两者的调整逻辑有所区别Vector在扩容时会提高1倍而ArrayList则是增加50%。
LinkedList顾名思义是Java提供的双向链表所以它不需要像上面两种那样调整容量它也不是线程安全的。
考点分析
似乎从我接触Java开始这个问题就一直是经典的面试题前面我的回答覆盖了三者的一些基本的设计和实现。
一般来说,也可以补充一下不同容器类型适合的场景:
Vector和ArrayList作为动态数组其内部元素以数组形式顺序存储的所以非常适合随机访问的场合。除了尾部插入和删除元素往往性能会相对较差比如我们在中间位置插入一个元素需要移动后续所有元素。
而LinkedList进行节点插入、删除却要高效得多但是随机访问性能则要比动态数组慢。
所以,在应用开发中,如果事先可以估计到,应用操作是偏向于插入、删除,还是随机访问较多,就可以针对性的进行选择。这也是面试最常见的一个考察角度,给定一个场景,选择适合的数据结构,所以对于这种典型选择一定要掌握清楚。
考察Java集合框架我觉得有很多方面需要掌握
Java集合框架的设计结构至少要有一个整体印象。
Java提供的主要容器集合和Map类型了解或掌握对应的数据结构、算法思考具体技术选择。
将问题扩展到性能、并发等领域。
集合框架的演进与发展。
作为Java专栏我会在尽量围绕Java相关进行扩展否则光是罗列集合部分涉及的数据结构就要占用很大篇幅。这并不代表那些不重要数据结构和算法是基本功往往也是必考的点有些公司甚至以考察这些方面而非常知名甚至是“臭名昭著”。我这里以需要掌握典型排序算法为例你至少需要熟知
内部排序,至少掌握基础算法如归并排序、交换排序(冒泡、快排)、选择排序、插入排序等。
外部排序,掌握利用内存和外部存储处理超大数据集,至少要理解过程和思路。
考察算法不仅仅是如何简单实现,面试官往往会刨根问底,比如哪些是排序是不稳定的呢(快排、堆排),或者思考稳定意味着什么;对不同数据集,各种排序的最好或最差情况;从某个角度如何进一步优化(比如空间占用,假设业务场景需要最小辅助空间,这个角度堆排序就比归并优异)等,从简单的了解,到进一步的思考,面试官通常还会观察面试者处理问题和沟通时的思路。
以上只是一个方面的例子建议学习相关书籍如《算法导论》《编程珠玑》等或相关教程。对于特定领域比如推荐系统建议咨询领域专家。单纯从面试的角度很多朋友推荐使用一些算法网站如LeetCode等帮助复习和准备面试但坦白说我并没有刷过这些算法题这也是仁者见仁智者见智的事情招聘时我更倾向于考察面试者自身最擅长的东西免得招到纯面试高手。
知识扩展
我们先一起来理解集合框架的整体设计为了有个直观的印象我画了一个简要的类图。注意为了避免混淆我这里没有把java.util.concurrent下面的线程安全容器添加进来也没有列出Map容器虽然通常概念上我们也会把Map作为集合框架的一部分但是它本身并不是真正的集合Collection
所以,我今天主要围绕狭义的集合框架,其他都会在专栏后面的内容进行讲解。
我们可以看到Java的集合框架Collection接口是所有集合的根然后扩展开提供了三大类集合分别是
List也就是我们前面介绍最多的有序集合它提供了方便的访问、插入、删除等操作。
SetSet是不允许重复元素的这是和List最明显的区别也就是不存在两个对象equals返回true。我们在日常开发中有很多需要保证元素唯一性的场合。
Queue/Deque则是Java提供的标准队列结构的实现除了集合的基本功能它还支持类似先入先出FIFO First-in-First-Out或者后入先出LIFOLast-In-First-Out等特定行为。这里不包括BlockingQueue因为通常是并发编程场合所以被放置在并发包里。
每种集合的通用逻辑都被抽象到相应的抽象类之中比如AbstractList就集中了各种List操作的通用部分。这些集合不是完全孤立的比如LinkedList本身既是List也是Deque哦。
如果阅读过更多源码你会发现其实TreeSet代码里实际默认是利用TreeMap实现的Java类库创建了一个Dummy对象“PRESENT”作为value然后所有插入的元素其实是以键的形式放入了TreeMap里面同理HashSet其实也是以HashMap为基础实现的原来他们只是Map类的马甲
就像前面提到过的我们需要对各种具体集合实现至少了解基本特征和典型使用场景以Set的几个实现为例
TreeSet支持自然顺序访问但是添加、删除、包含等操作要相对低效log(n)时间)。
HashSet则是利用哈希算法理想情况下如果哈希散列正常可以提供常数时间的添加、删除、包含等操作但是它不保证有序。
LinkedHashSet内部构建了一个记录插入顺序的双向链表因此提供了按照插入顺序遍历的能力与此同时也保证了常数时间的添加、删除、包含等操作这些操作性能略低于HashSet因为需要维护链表的开销。
在遍历元素时HashSet性能受自身容量影响所以初始化时除非有必要不然不要将其背后的HashMap容量设置过大。而对于LinkedHashSet由于其内部链表提供的方便遍历性能只和元素多少有关系。
我今天介绍的这些集合类都不是线程安全的对于java.util.concurrent里面的线程安全容器我在专栏后面会去介绍。但是并不代表这些集合完全不能支持并发编程的场景在Collections工具类中提供了一系列的synchronized方法比如
static <T> List<T> synchronizedList(List<T> list)
我们完全可以利用类似方法来实现基本的线程安全集合:
List list = Collections.synchronizedList(new ArrayList());
它的实现基本就是将每个基本方法比如get、set、add之类都通过synchronized添加基本的同步支持非常简单粗暴但也非常实用。注意这些方法创建的线程安全集合都符合迭代时fail-fast行为当发生意外的并发修改时尽早抛出ConcurrentModificationException异常以避免不可预计的行为。
另外一个经常会被考察到的问题就是理解Java提供的默认排序算法具体是什么排序方式以及设计思路等。
这个问题本身就是有点陷阱的意味因为需要区分是Arrays.sort()还是Collections.sort() 底层是调用Arrays.sort()什么数据类型多大的数据集太小的数据集复杂排序是没必要的Java会直接进行二分插入排序等。
对于原始数据类型目前使用的是所谓双轴快速排序Dual-Pivot QuickSort是一种改进的快速排序算法早期版本是相对传统的快速排序你可以阅读源码。
而对于对象数据类型目前则是使用TimSort思想上也是一种归并和二分插入排序binarySort结合的优化排序算法。TimSort并不是Java的独创简单说它的思路是查找数据集中已经排好序的分区这里叫run然后合并这些分区来达到排序的目的。
另外Java 8引入了并行排序算法直接使用parallelSort方法这是为了充分利用现代多核处理器的计算能力底层实现基于fork-join框架专栏后面会对fork-join进行相对详细的介绍当处理的数据集比较小的时候差距不明显甚至还表现差一点但是当数据集增长到数万或百万以上时提高就非常大了具体还是取决于处理器和系统环境。
排序算法仍然在不断改进最近双轴快速排序实现的作者提交了一个更进一步的改进历时多年的研究目前正在审核和验证阶段。根据作者的性能测试对比相比于基于归并排序的实现新改进可以提高随机数据排序速度提高10%20%,甚至在其他特征的数据集上也有几倍的提高,有兴趣的话你可以参考具体代码和介绍:-
http://mail.openjdk.java.net/pipermail/core-libs-dev/2018-January/051000.html 。
在Java 8之中Java平台支持了Lambda和Stream相应的Java集合框架也进行了大范围的增强以支持类似为集合创建相应stream或者parallelStream的方法实现我们可以非常方便的实现函数式代码。
阅读Java源代码你会发现这些API的设计和实现比较独特它们并不是实现在抽象类里面而是以默认方法的形式实现在Collection这样的接口里这是Java 8在语言层面的新特性允许接口实现默认方法理论上来说我们原来实现在类似Collections这种工具类中的方法大多可以转换到相应的接口上。针对这一点我在面向对象主题会专门梳理Java语言面向对象基本机制的演进。
在Java 9中Java标准类库提供了一系列的静态工厂方法比如List.of()、Set.of()大大简化了构建小的容器实例的代码量。根据业界实践经验我们发现相当一部分集合实例都是容量非常有限的而且在生命周期中并不会进行修改。但是在原有的Java类库中我们可能不得不写成
ArrayList<String> list = new ArrayList<>();
list.add("Hello");
list.add("World");
而利用新的容器静态工厂方法,一句代码就够了,并且保证了不可变性。
List<String> simpleList = List.of("Hello","world");
更进一步通过各种of静态工厂方法创建的实例还应用了一些我们所谓的最佳实践比如它是不可变的符合我们对线程安全的需求它因为不需要考虑扩容所以空间上更加紧凑等。
如果我们去看of方法的源码你还会发现一个特别有意思的地方我们知道Java已经支持所谓的可变参数varargs但是官方类库还是提供了一系列特定参数长度的方法看起来似乎非常不优雅为什么呢这其实是为了最优的性能JVM在处理变长参数的时候会有明显的额外开销如果你需要实现性能敏感的API也可以进行参考。
今天我从Verctor、ArrayList、LinkedList开始逐步分析其设计实现区别、适合的应用场景等并进一步对集合框架进行了简单的归纳介绍了集合框架从基础算法到API设计实现的各种改进希望能对你的日常开发和API设计能够有帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗留一道思考题给你先思考一个应用场景比如你需要实现一个云计算任务调度系统希望可以保证VIP客户的任务被优先处理你可以利用哪些数据结构或者标准的集合类型呢更进一步讲类似场景大多是基于什么数据结构呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,324 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 对比Hashtable、HashMap、TreeMap有什么不同
Map是广义Java集合框架中的另外一部分HashMap作为框架中使用频率最高的类型之一它本身以及相关类型自然也是面试考察的热点。
今天我要问你的问题是对比Hashtable、HashMap、TreeMap有什么不同谈谈你对HashMap的掌握。
典型回答
Hashtable、HashMap、TreeMap都是最常见的一些Map实现是以键值对的形式存储和操作数据的容器类型。
Hashtable是早期Java类库提供的一个哈希表实现本身是同步的不支持null键和值由于同步导致的性能开销所以已经很少被推荐使用。
HashMap是应用更加广泛的哈希表实现行为上大致上与HashTable一致主要区别在于HashMap不是同步的支持null键和值等。通常情况下HashMap进行put或者get操作可以达到常数时间的性能所以它是绝大部分利用键值对存取场景的首选比如实现一个用户ID和用户信息对应的运行时存储结构。
TreeMap则是基于红黑树的一种提供顺序访问的Map和HashMap不同它的get、put、remove之类操作都是Olog(n)的时间复杂度具体顺序可以由指定的Comparator来决定或者根据键的自然顺序来判断。
考点分析
上面的回答只是对一些基本特征的简单总结针对Map相关可以扩展的问题很多从各种数据结构、典型应用场景到程序设计实现的技术考量尤其是在Java 8里HashMap本身发生了非常大的变化这些都是经常考察的方面。
很多朋友向我反馈面试官似乎钟爱考察HashMap的设计和实现细节所以今天我会增加相应的源码解读主要专注于下面几个方面
理解Map相关类似整体结构尤其是有序数据结构的一些要点。
从源码去分析HashMap的设计和实现要点理解容量、负载因子等为什么需要这些参数如何影响Map的性能实践中如何取舍等。
理解树化改造的相关原理和改进原因。
除了典型的代码分析还有一些有意思的并发相关问题也经常会被提到如HashMap在并发环境可能出现无限循环占用CPU、size不准确等诡异的问题。
我认为这是一种典型的使用错误因为HashMap明确声明不是线程安全的数据结构如果忽略这一点简单用在多线程场景里难免会出现问题。
理解导致这种错误的原因,也是深入理解并发程序运行的好办法。对于具体发生了什么,你可以参考这篇很久以前的分析,里面甚至提供了示意图,我就不再重复别人写好的内容了。
知识扩展
Map整体结构
首先我们先对Map相关类型有个整体了解Map虽然通常被包括在Java集合框架里但是其本身并不是狭义上的集合类型Collection具体你可以参考下面这个简单类图。
Hashtable比较特别作为类似Vector、Stack的早期集合相关类型它是扩展了Dictionary类的类结构上与HashMap之类明显不同。
HashMap等其他Map实现则是都扩展了AbstractMap里面包含了通用方法抽象。不同Map的用途从类图结构就能体现出来设计目的已经体现在不同接口上。
大部分使用Map的场景通常就是放入、访问或者删除而对顺序没有特别要求HashMap在这种情况下基本是最好的选择。HashMap的性能表现非常依赖于哈希码的有效性请务必掌握hashCode和equals的一些基本约定比如
equals相等hashCode一定要相等。
重写了hashCode也要重写equals。
hashCode需要保持一致性状态改变返回的哈希值仍然要一致。
equals的对称、反射、传递等特性。
这方面内容网上有很多资料,我就不在这里详细展开了。
针对有序Map的分析内容比较有限我再补充一些虽然LinkedHashMap和TreeMap都可以保证某种顺序但二者还是非常不同的。
LinkedHashMap通常提供的是遍历顺序符合插入顺序它的实现是通过为条目键值对维护一个双向链表。注意通过特定构造函数我们可以创建反映访问顺序的实例所谓的put、get、compute等都算作“访问”。
这种行为适用于一些特定应用场景例如我们构建一个空间占用敏感的资源池希望可以自动将最不常被访问的对象释放掉这就可以利用LinkedHashMap提供的机制来实现参考下面的示例
import java.util.LinkedHashMap;
import java.util.Map;
public class LinkedHashMapSample {
public static void main(String[] args) {
LinkedHashMap<String, String> accessOrderedMap = new LinkedHashMap<String, String>(16, 0.75F, true){
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) { // 实现自定义删除策略否则行为就和普遍Map没有区别
return size() > 3;
}
};
accessOrderedMap.put("Project1", "Valhalla");
accessOrderedMap.put("Project2", "Panama");
accessOrderedMap.put("Project3", "Loom");
accessOrderedMap.forEach( (k,v) -> {
System.out.println(k +":" + v);
});
// 模拟访问
accessOrderedMap.get("Project2");
accessOrderedMap.get("Project2");
accessOrderedMap.get("Project3");
System.out.println("Iterate over should be not affected:");
accessOrderedMap.forEach( (k,v) -> {
System.out.println(k +":" + v);
});
// 触发删除
accessOrderedMap.put("Project4", "Mission Control");
System.out.println("Oldest entry should be removed:");
accessOrderedMap.forEach( (k,v) -> {// 遍历顺序不变
System.out.println(k +":" + v);
});
}
}
对于TreeMap它的整体顺序是由键的顺序关系决定的通过Comparator或Comparable自然顺序来决定。
我在上一讲留给你的思考题提到了构建一个具有优先级的调度系统的问题其本质就是个典型的优先队列场景Java标准库提供了基于二叉堆实现的PriorityQueue它们都是依赖于同一种排序机制当然也包括TreeMap的马甲TreeSet。
类似hashCode和equals的约定为了避免模棱两可的情况自然顺序同样需要符合一个约定就是compareTo的返回值需要和equals一致否则就会出现模棱两可情况。
我们可以分析TreeMap的put方法实现
public V put(K key, V value) {
Entry<K,V> t = …
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
// ...
}
从代码里,你可以看出什么呢? 当我不遵守约定时两个不符合唯一性equals要求的对象被当作是同一个因为compareTo返回0这会导致歧义的行为表现。
HashMap源码分析
前面提到HashMap设计与实现是个非常高频的面试题所以我会在这进行相对详细的源码解读主要围绕
HashMap内部实现基本点分析。
容量capacity和负载系数load factor
树化 。
首先我们来一起看看HashMap内部的结构它可以看作是数组Node[] table和链表结合组成的复合结构数组被分为一个个桶bucket通过哈希值决定了键值对在这个数组的寻址哈希值相同的键值对则以链表形式存储你可以参考下面的示意图。这里需要注意的是如果链表大小超过阈值TREEIFY_THRESHOLD, 8图中的链表就会被改造为树形结构。
从非拷贝构造函数的实现来看,这个表格(数组)似乎并没有在最初就初始化好,仅仅设置了一些初始值而已。
public HashMap(int initialCapacity, float loadFactor){
// ...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
所以我们深刻怀疑HashMap也许是按照lazy-load原则在首次使用时被初始化拷贝构造函数除外我这里仅介绍最通用的场景。既然如此我们去看看put方法实现似乎只有一个putVal的调用
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
看来主要的秘密似乎藏在putVal里面到底有什么秘密呢为了节省空间我这里只截取了putVal比较关键的几部分。
final V putVal(int hash, K key, V value, boolean onlyIfAbent,
boolean evit) {
Node<K,V>[] tab; Node<K,V> p; int , i;
if ((tab = table) == null || (n = tab.length) = 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == ull)
tab[i] = newNode(hash, key, value, nll);
else {
// ...
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for first
treeifyBin(tab, hash);
// ...
}
}
从putVal方法最初的几行我们就可以发现几个有意思的地方
如果表格是nullresize方法会负责初始化它这从tab = resize()可以看出。
resize方法兼顾两个职责创建初始存储表格或者在容量不满足需求的时候进行扩容resize
在放置新的键值对的过程中,如果发生下面条件,就会发生扩容。
if (++size > threshold)
resize();
具体键值对在哈希表中的位置数组index取决于下面的位运算
i = (n - 1) & hash
仔细观察哈希值的源头我们会发现它并不是key本身的hashCode而是来自于HashMap内部的另外一个hash方法。注意为什么这里需要将高位数据移位到低位进行异或运算呢这是因为有些数据计算出的哈希值差异主要在高位而HashMap里的哈希寻址是忽略容量以上的高位的那么这种处理就可以有效避免类似情况下的哈希碰撞。
static final int hash(Object kye) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>>16;
}
我前面提到的链表结构这里叫bin会在达到一定门限值时发生树化我稍后会分析为什么HashMap需要对bin进行处理。
可以看到putVal方法本身逻辑非常集中从初始化、扩容到树化全部都和它有关推荐你阅读源码的时候可以参考上面的主要逻辑。
我进一步分析一下身兼多职的resize方法很多朋友都反馈经常被面试官追问它的源码设计。
final Node<K,V>[] resize() {
// ...
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACIY &&
oldCap >= DEFAULT_INITIAL_CAPAITY)
newThr = oldThr << 1; // double there
// ...
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
// zero initial threshold signifies using defaultsfults
newCap = DEFAULT_INITIAL_CAPAITY;
newThr = (int)(DEFAULT_LOAD_ATOR* DEFAULT_INITIAL_CAPACITY
}
if (newThr ==0) {
float ft = (float)newCap * loadFator;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);
}
threshold = neThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newap];
table = n
// 移动到新的数组结构e数组结构
}
依据resize源码不考虑极端情况容量理论最大极限由MAXIMUM_CAPACITY指定数值为 1<
门限值等于负载因子x容量如果构建HashMap的时候没有指定它们那么就是依据相应的默认常量值
门限通常是以倍数进行调整 newThr = oldThr << 1我前面提到根据putVal中的逻辑当元素个数超过门限大小时则调整Map大小
扩容后需要将老的数组中的元素重新放置到新的数组这是扩容的一个主要开销来源
容量负载因子和树化
前面我们快速梳理了一下HashMap从创建到放入键值对的相关逻辑现在思考一下为什么我们需要在乎容量和负载因子呢
这是因为容量和负载系数决定了可用的桶的数量空桶太多会浪费空间如果使用的太满则会严重影响操作的性能极端情况下假设只有一个桶那么它就退化成了链表完全不能提供所谓常数时间存的性能
既然容量和负载因子这么重要我们在实践中应该如何选择呢
如果能够知道HashMap要存取的键值对数量可以考虑预先设置合适的容量大小具体数值我们可以根据扩容发生的条件来做简单预估根据前面的代码分析我们知道它需要符合计算条件
负载因子 * 容量 > 元素数量
所以,预先设置的容量需要满足,大于“预估元素数量/负载因子”同时它是2的幂数结论已经非常清晰了。
而对于负载因子,我建议:
如果没有特别需求不要轻易进行更改因为JDK自身的默认负载因子是非常符合通用场景的需求的。
如果确实需要调整建议不要设置超过0.75的数值因为会显著增加冲突降低HashMap的性能。
如果使用太小的负载因子,按照上面的公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销,本身访问性能也会受影响。
我们前面提到了树化改造对应逻辑主要在putVal和treeifyBin中。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
//树化改造逻辑
}
}
上面是精简过的treeifyBin示意综合这两个方法树化改造的逻辑就非常清晰了可以理解为当bin的数量大于TREEIFY_THRESHOLD时
如果容量小于MIN_TREEIFY_CAPACITY只会进行简单的扩容
如果容量大于MIN_TREEIFY_CAPACITY 则会进行树化改造
那么为什么HashMap要树化呢
本质上这是个安全问题因为在元素放置过程中如果一个对象哈希冲突都被放置到同一个桶里则会形成一个链表我们知道链表查询是线性的会严重影响存取的性能
而在现实世界构造哈希冲突的数据并不是非常复杂的事情恶意代码就可以利用这些数据大量与服务器端交互导致服务器端CPU大量占用这就构成了哈希碰撞拒绝服务攻击国内一线互联网公司就发生过类似攻击事件
今天我从Map相关的几种实现对比对各种Map进行了分析讲解了有序集合类型容易混淆的地方并从源码级别分析了HashMap的基本结构希望对你有所帮助
一课一练
关于今天我们讨论的题目你做到心中有数了吗留一道思考题给你解决哈希冲突有哪些典型方法呢
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习鼓励金欢迎你与我一起讨论
你的朋友是不是也在准备面试呢你可以请朋友读”,把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,319 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 如何保证集合是线程安全的_ ConcurrentHashMap如何实现高效地线程安全
我在之前两讲介绍了Java集合框架的典型容器类它们绝大部分都不是线程安全的仅有的线程安全实现比如Vector、Stack在性能方面也远不尽如人意。幸好Java语言提供了并发包java.util.concurrent为高度并发需求提供了更加全面的工具支持。
今天我要问你的问题是如何保证容器是线程安全的ConcurrentHashMap如何实现高效地线程安全
典型回答
Java提供了不同层面的线程安全支持。在传统集合框架内部除了Hashtable等同步容器还提供了所谓的同步包装器Synchronized Wrapper我们可以调用Collections工具类提供的包装方法来获取一个同步的包装容器如Collections.synchronizedMap但是它们都是利用非常粗粒度的同步方式在高并发情况下性能比较低下。
另外,更加普遍的选择是利用并发包提供的线程安全容器类,它提供了:
各种并发容器比如ConcurrentHashMap、CopyOnWriteArrayList。
各种线程安全队列Queue/Deque如ArrayBlockingQueue、SynchronousQueue。
各种有序容器的线程安全版本等。
具体保证线程安全的方式包括有从简单的synchronize方式到基于更加精细化的比如基于分离锁实现的ConcurrentHashMap等并发实现等。具体选择要看开发的场景需求总体来说并发包内提供的容器通用场景远优于早期的简单同步实现。
考点分析
谈到线程安全和并发可以说是Java面试中必考的考点我上面给出的回答是一个相对宽泛的总结而且ConcurrentHashMap等并发容器实现也在不断演进不能一概而论。
如果要深入思考并回答这个问题及其扩展方面,至少需要:
理解基本的线程安全工具。
理解传统集合框架并发编程中Map存在的问题清楚简单同步方式的不足。
梳理并发包内尤其是ConcurrentHashMap采取了哪些方法来提高并发表现。
最好能够掌握ConcurrentHashMap自身的演进目前的很多分析资料还是基于其早期版本。
今天我主要是延续专栏之前两讲的内容重点解读经常被同时考察的HashMap和ConcurrentHashMap。今天这一讲并不是对并发方面的全面梳理毕竟这也不是专栏一讲可以介绍完整的算是个开胃菜吧类似CAS等更加底层的机制后面会在Java进阶模块中的并发主题有更加系统的介绍。
知识扩展
为什么需要ConcurrentHashMap
Hashtable本身比较低效因为它的实现基本就是将put、get、size等各种方法加上“synchronized”。简单来说这就导致了所有并发操作都要竞争同一把锁一个线程在进行同步操作时其他线程只能等待大大降低了并发操作的效率。
前面已经提过HashMap不是线程安全的并发情况会导致类似CPU占用100%等一些问题那么能不能利用Collections提供的同步包装器来解决问题呢
看看下面的代码片段我们发现同步包装器只是利用输入Map构造了另一个同步版本所有操作虽然不再声明成为synchronized方法但是还是利用了“this”作为互斥的mutex没有真正意义上的改进
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
// …
public int size() {
synchronized (mutex) {return m.size();}
}
// …
}
所以Hashtable或者同步包装版本都只是适合在非高度并发的场景下。
ConcurrentHashMap分析
我们再来看看ConcurrentHashMap是如何设计实现的为什么它能大大提高并发效率。
首先我这里强调ConcurrentHashMap的设计实现其实一直在演化比如在Java 8中就发生了非常大的变化Java 7其实也有不少更新所以我这里将比较分析结构、实现机制等方面对比不同版本的主要区别。
早期ConcurrentHashMap其实现是基于
分离锁也就是将内部进行分段Segment里面则是HashEntry的数组和HashMap类似哈希相同的条目也是以链表形式存放。
HashEntry内部使用volatile的value字段来保证可见性也利用了不可变对象的机制以改进利用Unsafe提供的底层能力比如volatile access去直接完成部分操作以最优化性能毕竟Unsafe中的很多操作都是JVM intrinsic优化过的。
你可以参考下面这个早期ConcurrentHashMap内部结构的示意图其核心是利用分段设计在进行并发操作的时候只需要锁定相应段这样就有效避免了类似Hashtable整体同步的问题大大提高了性能。
在构造的时候Segment的数量由所谓的concurrencyLevel决定默认是16也可以在相应构造函数直接指定。注意Java需要它是2的幂数值如果输入是类似15这种非幂值会被自动调整到16之类2的幂数值。
具体情况我们一起看看一些Map基本操作的源码这是JDK 7比较新的get代码。针对具体的优化部分为方便理解我直接注释在代码段里get操作需要保证的是可见性所以并没有什么同步逻辑。
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key.hashCode());
//利用位操作替换普通数学运算
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 以Segment为单位进行定位
// 利用Unsafe直接进行volatile access
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
//省略
}
return null;
}
而对于put操作首先是通过二次哈希避免哈希冲突然后以Unsafe调用方式直接获取相应的Segment然后进行线程安全的put操作
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 二次哈希,以保证数据的分散性,避免哈希冲突
int hash = hash(key.hashCode());
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
其核心逻辑实现在下面的内部方法中
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// scanAndLockForPut会去查找是否有key相同Node
// 无论如何确保获取锁
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// 更新已有value...
}
else {
// 放置HashEntry到特定位置如果超过阈值进行rehash
// ...
}
}
} finally {
unlock();
}
return oldValue;
}
所以,从上面的源码清晰的看出,在进行并发写操作时:
ConcurrentHashMap会获取再入锁以保证数据一致性Segment本身就是基于ReentrantLock的扩展实现所以在并发修改期间相应Segment是被锁定的。
在最初阶段进行重复性的扫描以确定相应key值是否已经在数组里面进而决定是更新还是放置操作你可以在代码里看到相应的注释。重复扫描、检测冲突是ConcurrentHashMap的常见技巧。
我在专栏上一讲介绍HashMap时提到了可能发生的扩容问题在ConcurrentHashMap中同样存在。不过有一个明显区别就是它进行的不是整体的扩容而是单独对Segment进行扩容细节就不介绍了。
另外一个Map的size方法同样需要关注它的实现涉及分离锁的一个副作用。
试想如果不进行同步简单的计算所有Segment的总值可能会因为并发put导致结果不准确但是直接锁定所有Segment进行计算就会变得非常昂贵。其实分离锁也限制了Map的初始化等操作。
所以ConcurrentHashMap的实现是通过重试机制RETRIES_BEFORE_LOCK指定重试次数2来试图获得可靠值。如果没有监控到发生变化通过对比Segment.modCount就直接返回否则获取锁进行操作。
下面我来对比一下在Java 8和之后的版本中ConcurrentHashMap发生了哪些变化呢
总体结构上它的内部存储变得和我在专栏上一讲介绍的HashMap结构非常相似同样是大的桶bucket数组然后内部也是一个个所谓的链表结构bin同步的粒度要更细致一些。
其内部仍然有Segment定义但仅仅是为了保证序列化时的兼容性而已不再有任何结构上的用处。
因为不再使用Segment初始化操作大大简化修改为lazy-load形式这样可以有效避免初始开销解决了老版本很多人抱怨的这一点。
数据存储利用volatile来保证可见性。
使用CAS等操作在特定场景进行无锁并发操作。
使用Unsafe、LongAdder之类底层手段进行极端情况的优化。
先看看现在的数据存储内部实现我们可以发现Key是final的因为在生命周期中一个条目的Key发生变化是不可能的与此同时val则声明为volatile以保证可见性。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
// …
}
我这里就不再介绍get方法和构造函数了相对比较简单直接看并发的put是如何实现的。
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 利用CAS去进行无锁线程安全操作如果bin是空的
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // 不加锁,进行检查
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
else {
V oldVal = null;
synchronized (f) {
// 细粒度的同步修改操作...
}
}
// Bin超过阈值进行树化
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
初始化操作实现在initTable里面这是一个典型的CAS使用场景利用volatile的sizeCtl作为互斥手段如果发现竞争性的初始化就spin在那里等待条件恢复否则利用CAS设置排他标志。如果成功则进行初始化否则重试。
请参考下面代码:
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 如果发现冲突进行spin等待
if ((sc = sizeCtl) < 0)
Thread.yield();
// CAS成功返回true则进入真正的初始化逻辑
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
当bin为空时同样是没有必要锁定也是以CAS操作去放置。
你有没有注意到在同步逻辑上它使用的是synchronized而不是通常建议的ReentrantLock之类这是为什么呢现代JDK中synchronized已经被不断优化可以不再过分担心性能差异另外相比于ReentrantLock它可以减少内存消耗这是个非常大的优势。
与此同时更多细节实现通过使用Unsafe进行了优化例如tabAt就是直接利用getObjectAcquire避免间接调用的开销。
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE);
}
再看看现在是如何实现size操作的阅读代码你会发现真正的逻辑是在sumCount方法中 那么sumCount做了什么呢
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
我们发现虽然思路仍然和以前类似都是分而治之的进行计数然后求和处理但实现却基于一个奇怪的CounterCell 难道它的数值就更加准确吗数据一致性是怎么保证的
static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
其实对于CounterCell的操作是基于java.util.concurrent.atomic.LongAdder进行的是一种JVM利用空间换取更高效率的方法利用了Striped64内部的复杂逻辑这个东西非常小众大多数情况下建议还是使用AtomicLong足以满足绝大部分应用的性能需求
今天我从线程安全问题开始概念性的总结了基本容器工具分析了早期同步容器的问题进而分析了Java 7和Java 8中ConcurrentHashMap是如何设计实现的希望ConcurrentHashMap的并发技巧对你在日常开发可以有所帮助
一课一练
关于今天我们讨论的题目你做到心中有数了吗留一个道思考题给你在产品代码中有没有典型的场景需要使用类似ConcurrentHashMap这样的并发容器呢
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习鼓励金欢迎你与我一起讨论
你的朋友是不是也在准备面试呢你可以请朋友读”,把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,279 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 Java提供了哪些IO方式 NIO如何实现多路复用
IO一直是软件开发中的核心部分之一伴随着海量数据增长和分布式系统的发展IO扩展能力愈发重要。幸运的是Java平台IO机制经过不断完善虽然在某些方面仍有不足但已经在实践中证明了其构建高扩展性应用的能力。
今天我要问你的问题是Java提供了哪些IO方式 NIO如何实现多路复用
典型回答
Java IO方式有很多种基于不同的IO抽象模型和交互方式可以进行简单区分。
第一传统的java.io包它基于流模型实现提供了我们最熟知的一些IO功能比如File抽象、输入输出流等。交互方式是同步、阻塞的方式也就是说在读取输入流或者写入输出流时在读、写动作完成之前线程会一直阻塞在那里它们之间的调用是可靠的线性顺序。
java.io包的好处是代码比较简单、直观缺点则是IO效率和扩展性存在局限性容易成为应用性能的瓶颈。
很多时候人们也把java.net下面提供的部分网络API比如Socket、ServerSocket、HttpURLConnection也归类到同步阻塞IO类库因为网络通信同样是IO行为。
第二在Java 1.4中引入了NIO框架java.nio包提供了Channel、Selector、Buffer等新的抽象可以构建多路复用的、同步非阻塞IO程序同时提供了更接近操作系统底层的高性能数据操作方式。
第三在Java 7中NIO有了进一步的改进也就是NIO 2引入了异步非阻塞IO方式也有很多人叫它AIOAsynchronous IO。异步IO操作基于事件和回调机制可以简单理解为应用操作直接返回而不会阻塞在那里当后台处理完成操作系统会通知相应线程进行后续工作。
考点分析
我上面列出的回答是基于一种常见分类方式即所谓的BIO、NIO、NIO 2AIO
在实际面试中从传统IO到NIO、NIO 2其中有很多地方可以扩展开来考察点涉及方方面面比如
基础API功能与设计 InputStream/OutputStream和Reader/Writer的关系和区别。
NIO、NIO 2的基本组成。
给定场景分别用不同模型实现分析BIO、NIO等模式的设计和实现原理。
NIO提供的高性能数据操作方式是基于什么原理如何使用
或者从开发者的角度来看你觉得NIO自身实现存在哪些问题有什么改进的想法吗
IO的内容比较多专栏一讲很难能够说清楚。IO不仅仅是多路复用NIO 2也不仅仅是异步IO尤其是数据操作部分会在专栏下一讲详细分析。
知识扩展
首先,需要澄清一些基本概念:
区分同步或异步synchronous/asynchronous。简单来说同步是一种可靠的有序运行机制当我们进行同步操作时后续的任务是等待当前调用返回才会进行下一步而异步则相反其他任务不需要等待当前调用返回通常依靠事件、回调等机制来实现任务间次序关系。
区分阻塞与非阻塞blocking/non-blocking。在进行阻塞操作时当前线程会处于阻塞状态无法从事其他任务只有当条件就绪才能继续比如ServerSocket新连接建立完毕或数据读取、写入操作完成而非阻塞则是不管IO操作是否结束直接返回相应操作在后台继续处理。
不能一概而论认为同步或阻塞就是低效,具体还要看应用和系统特征。
对于java.io我们都非常熟悉我这里就从总体上进行一下总结如果需要学习更加具体的操作你可以通过教程等途径完成。总体上我认为你至少需要理解一下内容。
IO不仅仅是对文件的操作网络编程中比如Socket通信都是典型的IO操作目标。
输入流、输出流InputStream/OutputStream是用于读取或写入字节的例如操作图片文件。
而Reader/Writer则是用于操作字符增加了字符编解码等功能适用于类似从文件中读取或者写入文本信息。本质上计算机操作的都是字节不管是网络通信还是文件读取Reader/Writer相当于构建了应用逻辑和原始数据之间的桥梁。
BufferedOutputStream等带缓冲区的实现可以避免频繁的磁盘读写进而提高IO处理效率。这种设计利用了缓冲区将批量数据进行一次操作但在使用中千万别忘了flush。
参考下面这张类图很多IO工具类都实现了Closeable接口因为需要进行资源的释放。比如打开FileInputStream它就会获取相应的文件描述符FileDescriptor需要利用try-with-resources、 try-finally等机制保证FileInputStream被明确关闭进而相应文件描述符也会失效否则将导致资源无法被释放。利用专栏前面的内容提到的Cleaner或finalize机制作为资源释放的最后把关也是必要的。
下面是我整理的一个简化版的类图,阐述了日常开发应用较多的类型和结构关系。
1. Java NIO概览
首先熟悉一下NIO的主要组成部分
Buffer高效的数据容器除了布尔类型所有原始数据类型都有相应的Buffer实现。
Channel类似在Linux之类操作系统上看到的文件描述符是NIO中被用来支持批量式IO操作的一种抽象。
File或者Socket通常被认为是比较高层次的抽象而Channel则是更加操作系统底层的一种抽象这也使得NIO得以充分利用现代操作系统底层机制获得特定场景的性能优化例如DMADirect Memory Access等。不同层次的抽象是相互关联的我们可以通过Socket获取Channel反之亦然。
Selector是NIO实现多路复用的基础它提供了一种高效的机制可以检测到注册在Selector上的多个Channel中是否有Channel处于就绪状态进而实现了单线程对多Channel的高效管理。Selector同样是基于底层操作系统机制不同模式、不同版本都存在区别例如在最新的代码库里相关实现如下
Linux上依赖于epollWindows上NIO2AIO模式则是依赖于iocp。
Charset提供Unicode字符串定义NIO也提供了相应的编解码器等例如通过下面的方式进行字符串到ByteBuffer的转换
Charset.defaultCharset().encode(“Hello world!”));
2. NIO能解决什么问题
下面我通过一个典型场景来分析为什么需要NIO为什么需要多路复用。设想我们需要实现一个服务器应用只简单要求能够同时服务多个客户端请求即可。
使用java.io和java.net中的同步、阻塞式API可以简单实现。
public class DemoServer extends Thread {
private ServerSocket serverSocket;
public int getPort() {
return serverSocket.getLocalPort();
}
public void run() {
try {
serverSocket = new ServerSocket(0);
while (true) {
Socket socket = serverSocket.accept();
RequestHandler requestHandler = new RequestHandler(socket);
requestHandler.start();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (serverSocket != null) {
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
;
}
}
}
public static void main(String[] args) throws IOException {
DemoServer server = new DemoServer();
server.start();
try (Socket client = new Socket(InetAddress.getLocalHost(), server.getPort())) {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(client.getInputStream()));
bufferedReader.lines().forEach(s -> System.out.println(s));
}
}
}
// 简化实现,不做读取,直接发送字符串
class RequestHandler extends Thread {
private Socket socket;
RequestHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try (PrintWriter out = new PrintWriter(socket.getOutputStream());) {
out.println("Hello world!");
out.flush();
} catch (Exception e) {
e.printStackTrace();
}
}
}
其实现要点是:
服务器端启动ServerSocket端口0表示自动绑定一个空闲端口。
调用accept方法阻塞等待客户端连接。
利用Socket模拟了一个简单的客户端只进行连接、读取、打印。
当连接建立后,启动一个单独线程负责回复客户端请求。
这样一个简单的Socket服务器就被实现出来了。
思考一下,这个解决方案在扩展性方面,可能存在什么潜在问题呢?
大家知道Java语言目前的线程实现是比较重量级的启动或者销毁一个线程是有明显开销的每个线程都有单独的线程栈等结构需要占用非常明显的内存所以每一个Client启动一个线程似乎都有些浪费。
那么,稍微修正一下这个问题,我们引入线程池机制来避免浪费。
serverSocket = new ServerSocket(0);
executor = Executors.newFixedThreadPool(8);
while (true) {
Socket socket = serverSocket.accept();
RequestHandler requestHandler = new RequestHandler(socket);
executor.execute(requestHandler);
}
这样做似乎好了很多,通过一个固定大小的线程池,来负责管理工作线程,避免频繁创建、销毁线程的开销,这是我们构建并发服务的典型方式。这种工作方式,可以参考下图来理解。
如果连接数并不是非常多,只有最多几百个连接的普通应用,这种模式往往可以工作的很好。但是,如果连接数量急剧上升,这种实现方式就无法很好地工作了,因为线程上下文切换开销会在高并发时变得很明显,这是同步阻塞方式的低扩展性劣势。
NIO引入的多路复用机制提供了另外一种思路请参考我下面提供的新的版本。
public class NIOServer extends Thread {
public void run() {
try (Selector selector = Selector.open();
ServerSocketChannel serverSocket = ServerSocketChannel.open();) {// 创建Selector和Channel
serverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888));
serverSocket.configureBlocking(false);
// 注册到Selector并说明关注点
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();// 阻塞等待就绪的Channel这是关键点之一
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iter = selectedKeys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
// 生产系统中一般会额外进行就绪状态检查
sayHelloWorld((ServerSocketChannel) key.channel());
iter.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
private void sayHelloWorld(ServerSocketChannel server) throws IOException {
try (SocketChannel client = server.accept();) { client.write(Charset.defaultCharset().encode("Hello world!"));
}
}
// 省略了与前面类似的main
}
这个非常精简的样例掀开了NIO多路复用的面纱我们可以分析下主要步骤和元素
首先通过Selector.open()创建一个Selector作为类似调度员的角色。
然后创建一个ServerSocketChannel并且向Selector注册通过指定SelectionKey.OP_ACCEPT告诉调度员它关注的是新的连接请求。
注意为什么我们要明确配置非阻塞模式呢这是因为阻塞模式下注册操作是不允许的会抛出IllegalBlockingModeException异常。
Selector阻塞在select操作当有Channel发生接入请求就会被唤醒。
在sayHelloWorld方法中通过SocketChannel和Buffer进行数据操作在本例中是发送了一段字符串。
可以看到在前面两个样例中IO都是同步阻塞模式所以需要多线程以实现多任务处理。而NIO则是利用了单线程轮询事件的机制通过高效地定位就绪的Channel来决定做什么仅仅select阶段是阻塞的可以有效避免大量客户端连接时频繁线程切换带来的问题应用的扩展能力有了非常大的提高。下面这张图对这种实现思路进行了形象地说明。
在Java 7引入的NIO 2中又增添了一种额外的异步IO模式利用事件和回调处理Accept、Read等操作。 AIO实现看起来是类似这样子
AsynchronousServerSocketChannel serverSock = AsynchronousServerSocketChannel.open().bind(sockAddr);
serverSock.accept(serverSock, new CompletionHandler<>() { //为异步操作指定CompletionHandler回调函数
@Override
public void completed(AsynchronousSocketChannel sockChannel, AsynchronousServerSocketChannel serverSock) {
serverSock.accept(serverSock, this);
// 另外一个 writesockCompletionHandler{}
sayHelloWorld(sockChannel, Charset.defaultCharset().encode
("Hello World!"));
}
// 省略其他路径处理方法...
});
鉴于其编程要素如Future、CompletionHandler等我们还没有进行准备工作为避免理解困难我会在专栏后面相关概念补充后的再进行介绍尤其是Reactor、Proactor模式等方面将在Netty主题一起分析这里我先进行概念性的对比
基本抽象很相似AsynchronousServerSocketChannel对应于上面例子中的ServerSocketChannelAsynchronousSocketChannel则对应SocketChannel。
业务逻辑的关键在于通过指定CompletionHandler回调接口在accept/read/write等关键节点通过事件机制调用这是非常不同的一种编程思路。
今天我初步对Java提供的IO机制进行了介绍概要地分析了传统同步IO和NIO的主要组成并根据典型场景通过不同的IO模式进行了实现与拆解。专栏下一讲我还将继续分析Java IO的主题。
一课一练
关于今天我们讨论的题目你做到心中有数了吗留一道思考题给你NIO多路复用的局限性是什么呢你遇到过相关的问题吗
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,267 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 Java有几种文件拷贝方式哪一种最高效-极客时间
我在专栏上一讲提到NIO 不止是多路复用NIO 2 也不只是异步 IO今天我们来看看 Java IO 体系中,其他不可忽略的部分。
今天我要问你的问题是Java 有几种文件拷贝方式?哪一种最高效?
典型回答
Java 有多种比较典型的文件拷贝实现方式,比如:
利用 java.io 类库,直接为源文件构建一个 FileInputStream 读取,然后再为目标文件构建一个 FileOutputStream完成写入工作。
public static void copyFileByStream(File source, File dest) throws
IOException {
try (InputStream is = new FileInputStream(source);
OutputStream os = new FileOutputStream(dest);){
byte[] buffer = new byte[1024];
int length;
while ((length = is.read(buffer)) > 0) {
os.write(buffer, 0, length);
}
}
}
或者,利用 java.nio 类库提供的 transferTo 或 transferFrom 方法实现。
public static void copyFileByChannel(File source, File dest) throws
IOException {
try (FileChannel sourceChannel = new FileInputStream(source)
.getChannel();
FileChannel targetChannel = new FileOutputStream(dest).getChannel
();){
for (long count = sourceChannel.size() ;count>0 ;) {
long transferred = sourceChannel.transferTo(
sourceChannel.position(), count, targetChannel); sourceChannel.position(sourceChannel.position() + transferred);
count -= transferred;
}
}
}
当然Java 标准类库本身已经提供了几种 Files.copy 的实现。
对于 Copy 的效率这个其实与操作系统和配置等情况相关总体上来说NIO transferTo/From 的方式可能更快,因为它更能利用现代操作系统底层机制,避免不必要拷贝和上下文切换。
考点分析
今天这个问题,从面试的角度来看,确实是一个面试考察的点,针对我上面的典型回答,面试官还可能会从实践角度,或者 IO 底层实现机制等方面进一步提问。这一讲的内容从面试题出发,主要还是为了让你进一步加深对 Java IO 类库设计和实现的了解。
从实践角度,我前面并没有明确说 NIO transfer 的方案一定最快,真实情况也确实未必如此。我们可以根据理论分析给出可行的推断,保持合理的怀疑,给出验证结论的思路,有时候面试官考察的就是如何将猜测变成可验证的结论,思考方式远比记住结论重要。
从技术角度展开,下面这些方面值得注意:
不同的 copy 方式,底层机制有什么区别?
为什么零拷贝zero-copy可能有性能优势
Buffer 分类与使用。
Direct Buffer 对垃圾收集等方面的影响与实践选择。
接下来,我们一起来分析一下吧。
知识扩展
拷贝实现机制分析
先来理解一下,前面实现的不同拷贝方法,本质上有什么明显的区别。
首先你需要理解用户态空间User Space和内核态空间Kernel Space这是操作系统层面的基本概念操作系统内核、硬件驱动等运行在内核态空间具有相对高的特权而用户态空间则是给普通应用和服务使用。你可以参考https://en.wikipedia.org/wiki/User_space。
当我们使用输入输出流进行读写时,实际上是进行了多次上下文切换,比如应用读取数据时,先在内核态将数据从磁盘读取到内核缓存,再切换到用户态将数据从内核缓存读取到用户缓存。
写入操作也是类似,仅仅是步骤相反,你可以参考下面这张图。
所以,这种方式会带来一定的额外开销,可能会降低 IO 效率。
而基于 NIO transferTo 的实现方式,在 Linux 和 Unix 上则会使用到零拷贝技术数据传输并不需要用户态参与省去了上下文切换的开销和不必要的内存拷贝进而可能提高应用拷贝性能。注意transferTo 不仅仅是可以用在文件拷贝中,与其类似的,例如读取磁盘文件,然后进行 Socket 发送,同样可以享受这种机制带来的性能和扩展性提高。
transferTo 的传输过程是:
Java IO/NIO 源码结构
前面我在典型回答中提了第三种方式,即 Java 标准库也提供了文件拷贝方法java.nio.file.Files.copy。如果你这样回答就一定要小心了因为很少有问题的答案是仅仅调用某个方法。从面试的角度面试官往往会追问既然你提到了标准库那么它是怎么实现的呢有的公司面试官以喜欢追问而出名直到追问到你说不知道。
其实,这个问题的答案还真不是那么直观,因为实际上有几个不同的 copy 方法。
public static Path copy(Path source, Path target, CopyOption... options)
throws IOException
public static long copy(InputStream in, Path target, CopyOption... options)
throws IOException
public static long copy(Path source, OutputStream out)
throws IOException
可以看到copy 不仅仅是支持文件之间操作,没有人限定输入输出流一定是针对文件的,这是两个很实用的工具方法。
后面两种 copy 实现,能够在方法实现里直接看到使用的是 InputStream.transferTo(),你可以直接看源码,其内部实现其实是 stream 在用户态的读写;而对于第一种方法的分析过程要相对麻烦一些,可以参考下面片段。简单起见,我只分析同类型文件系统拷贝过程。
public static Path copy(Path source, Path target, CopyOption... options)
throws IOException
{
FileSystemProvider provider = provider(source);
if (provider(target) == provider) {
// same provider
provider.copy(source, target, options);//这是本文分析的路径
} else {
// different providers
CopyMoveHelper.copyToForeignTarget(source, target, options);
}
return target;
}
我把源码分析过程简单记录如下JDK 的源代码中,内部实现和公共 API 定义也不是可以能够简单关联上的NIO 部分代码甚至是定义为模板而不是 Java 源文件,在 build 过程自动生成源码,下面顺便介绍一下部分 JDK 代码机制和如何绕过隐藏障碍。
首先,直接跟踪,发现 FileSystemProvider 只是个抽象类,阅读它的源码能够理解到,原来文件系统实际逻辑存在于 JDK 内部实现里,公共 API 其实是通过 ServiceLoader 机制加载一系列文件系统实现,然后提供服务。
我们可以在 JDK 源码里搜索 FileSystemProvider 和 nio可以定位到sun/nio/fs我们知道 NIO 底层是和操作系统紧密相关的,所以每个平台都有自己的部分特有文件系统逻辑。
省略掉一些细节,最后我们一步步定位到 UnixFileSystemProvider → UnixCopyFile.Transfer发现这是个本地方法。
最后明确定位到UnixCopyFile.c其内部实现清楚说明竟然只是简单的用户态空间拷贝
所以,我们明确这个最常见的 copy 方法其实不是利用 transferTo而是本地技术实现的用户态拷贝。
前面谈了不少机制和源码,我简单从实践角度总结一下,如何提高类似拷贝等 IO 操作的性能,有一些宽泛的原则:
在程序中,使用缓存等机制,合理减少 IO 次数(在网络通信中,如 TCP 传输window 大小也可以看作是类似思路)。
使用 transferTo 等机制,减少上下文切换和额外 IO 操作。
尽量减少不必要的转换过程,比如编解码;对象序列化和反序列化,比如操作文本文件或者网络通信,如果不是过程中需要使用文本信息,可以考虑不要将二进制信息转换成字符串,直接传输二进制信息。
掌握 NIO Buffer
我在上一讲提到 Buffer 是 NIO 操作数据的基本工具Java 为每种原始数据类型都提供了相应的 Buffer 实现(布尔除外),所以掌握和使用 Buffer 是十分必要的,尤其是涉及 Direct Buffer 等使用,因为其在垃圾收集等方面的特殊性,更要重点掌握。
Buffer 有几个基本属性:
capacity它反映这个 Buffer 到底有多大,也就是数组的长度。
position要操作的数据起始位置。
limit相当于操作的限额。在读取或者写入时limit 的意义很明显是不一样的。比如,读取操作时,很可能将 limit 设置到所容纳数据的上限;而在写入时,则会设置容量或容量以下的可写限度。
mark记录上一次 postion 的位置,默认是 0算是一个便利性的考虑往往不是必须的。
前面三个是我们日常使用最频繁的,我简单梳理下 Buffer 的基本操作:
我们创建了一个 ByteBuffer准备放入数据capacity 当然就是缓冲区大小,而 position 就是 0limit 默认就是 capacity 的大小。
当我们写入几个字节的数据时position 就会跟着水涨船高,但是它不可能超过 limit 的大小。
如果我们想把前面写入的数据读出来,需要调用 flip 方法,将 position 设置为 0limit 设置为以前的 position 那里。
如果还想从头再读一遍,可以调用 rewind让 limit 不变position 再次设置为 0。
更进一步的详细使用,我建议参考相关教程。
Direct Buffer 和垃圾收集
我这里重点介绍两种特别的 Buffer。
Direct Buffer如果我们看 Buffer 的方法定义,你会发现它定义了 isDirect() 方法,返回当前 Buffer 是否是 Direct 类型。这是因为 Java 提供了堆内和堆外DirectBuffer我们可以以它的 allocate 或者 allocateDirect 方法直接创建。
MappedByteBuffer它将文件按照指定大小直接映射为内存区域当程序访问这个内存区域时将直接操作这块儿文件数据省去了将数据从内核空间向用户空间传输的损耗。我们可以使用FileChannel.map创建 MappedByteBuffer它本质上也是种 Direct Buffer。
在实际使用中Java 会尽量对 Direct Buffer 仅做本地 IO 操作,对于很多大数据量的 IO 密集操作,可能会带来非常大的性能优势,因为:
Direct Buffer 生命周期内内存地址都不会再发生更改,进而内核可以安全地对其进行访问,很多 IO 操作会很高效。
减少了堆内对象存储的可能额外维护工作,所以访问效率可能有所提高。
但是请注意Direct Buffer 创建和销毁过程中,都会比一般的堆内 Buffer 增加部分开销,所以通常都建议用于长期使用、数据较大的场景。
使用 Direct Buffer我们需要清楚它对内存和 JVM 参数的影响。首先,因为它不在堆上,所以 Xmx 之类参数,其实并不能影响 Direct Buffer 等堆外成员所使用的内存额度,我们可以使用下面参数设置大小:
-XX:MaxDirectMemorySize=512M
从参数设置和内存问题排查角度来看,这意味着我们在计算 Java 可以使用的内存大小的时候,不能只考虑堆的需要,还有 Direct Buffer 等一系列堆外因素。如果出现内存不足,堆外内存占用也是一种可能性。
另外,大多数垃圾收集过程中,都不会主动收集 Direct Buffer它的垃圾收集过程就是基于我在专栏前面所介绍的 Cleaner一个内部实现和幻象引用PhantomReference机制其本身不是 public 类型,内部实现了一个 Deallocator 负责销毁的逻辑。对它的销毁往往要拖到 full GC 的时候,所以使用不当很容易导致 OutOfMemoryError。
对于 Direct Buffer 的回收,我有几个建议:
在应用程序中,显式地调用 System.gc() 来强制触发。
另外一种思路是,在大量使用 Direct Buffer 的部分框架中框架会自己在程序中调用释放方法Netty 就是这么做的有兴趣可以参考其实现PlatformDependent0
重复使用 Direct Buffer。
跟踪和诊断 Direct Buffer 内存占用?
因为通常的垃圾收集日志等记录,并不包含 Direct Buffer 等信息,所以 Direct Buffer 内存诊断也是个比较头疼的事情。幸好,在 JDK 8 之后的版本,我们可以方便地使用 Native Memory TrackingNMT特性来进行诊断你可以在程序启动时加上下面参数
-XX:NativeMemoryTracking={summary|detail}
注意,激活 NMT 通常都会导致 JVM 出现 5%~10% 的性能下降,请谨慎考虑。
运行时,可以采用下面命令进行交互式对比:
// 打印NMT信息
jcmd <pid> VM.native_memory detail
// 进行baseline以对比分配内存变化
jcmd <pid> VM.native_memory baseline
// 进行baseline以对比分配内存变化
jcmd <pid> VM.native_memory detail.diff
我们可以在 Internal 部分发现 Direct Buffer 内存使用的信息,这是因为其底层实际是利用 unsafe_allocatememory。严格说这不是 JVM 内部使用的内存,所以在 JDK 11 以后,其实它是归类在 other 部分里。
JDK 9 的输出片段如下,“+”表示的就是 diff 命令发现的分配变化:
-Internal (reserved=679KB +4KB, committed=679KB +4KB)
(malloc=615KB +4KB #1571 +4)
(mmap: reserved=64KB, committed=64KB)
注意JVM 的堆外内存远不止 Direct BufferNMT 输出的信息当然也远不止这些,我在专栏后面有综合分析更加具体的内存结构的主题。
今天我分析了 Java IO/NIO 底层文件操作数据的机制,以及如何实现零拷贝的高性能操作,梳理了 Buffer 的使用和类型,并针对 Direct Buffer 的生命周期管理和诊断进行了较详细的分析。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?你可以思考下,如果我们需要在 channel 读取的过程中,将不同片段写入到相应的 Buffer 里面(类似二进制消息分拆成消息头、消息体等),可以采用 NIO 的什么机制做到呢?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,283 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 Java有几种文件拷贝方式哪一种最高效
我在专栏上一讲提到NIO不止是多路复用NIO 2也不只是异步IO今天我们来看看Java IO体系中其他不可忽略的部分。
今天我要问你的问题是Java有几种文件拷贝方式哪一种最高效
典型回答
Java有多种比较典型的文件拷贝实现方式比如
利用java.io类库直接为源文件构建一个FileInputStream读取然后再为目标文件构建一个FileOutputStream完成写入工作。
public static void copyFileByStream(File source, File dest) throws
IOException {
try (InputStream is = new FileInputStream(source);
OutputStream os = new FileOutputStream(dest);){
byte[] buffer = new byte[1024];
int length;
while ((length = is.read(buffer)) > 0) {
os.write(buffer, 0, length);
}
}
}
或者利用java.nio类库提供的transferTo或transferFrom方法实现。
public static void copyFileByChannel(File source, File dest) throws
IOException {
try (FileChannel sourceChannel = new FileInputStream(source)
.getChannel();
FileChannel targetChannel = new FileOutputStream(dest).getChannel
();){
for (long count = sourceChannel.size() ;count>0 ;) {
long transferred = sourceChannel.transferTo(
sourceChannel.position(), count, targetChannel); sourceChannel.position(sourceChannel.position() + transferred);
count -= transferred;
}
}
}
当然Java标准类库本身已经提供了几种Files.copy的实现。
对于Copy的效率这个其实与操作系统和配置等情况相关总体上来说NIO transferTo/From的方式可能更快因为它更能利用现代操作系统底层机制避免不必要拷贝和上下文切换。
考点分析
今天这个问题从面试的角度来看确实是一个面试考察的点针对我上面的典型回答面试官还可能会从实践角度或者IO底层实现机制等方面进一步提问。这一讲的内容从面试题出发主要还是为了让你进一步加深对Java IO类库设计和实现的了解。
从实践角度我前面并没有明确说NIO transfer的方案一定最快真实情况也确实未必如此。我们可以根据理论分析给出可行的推断保持合理的怀疑给出验证结论的思路有时候面试官考察的就是如何将猜测变成可验证的结论思考方式远比记住结论重要。
从技术角度展开,下面这些方面值得注意:
不同的copy方式底层机制有什么区别
为什么零拷贝zero-copy可能有性能优势
Buffer分类与使用。
Direct Buffer对垃圾收集等方面的影响与实践选择。
接下来,我们一起来分析一下吧。
知识扩展
拷贝实现机制分析
先来理解一下,前面实现的不同拷贝方法,本质上有什么明显的区别。
首先你需要理解用户态空间User Space和内核态空间Kernel Space这是操作系统层面的基本概念操作系统内核、硬件驱动等运行在内核态空间具有相对高的特权而用户态空间则是给普通应用和服务使用。你可以参考https://en.wikipedia.org/wiki/User_space。
当我们使用输入输出流进行读写时,实际上是进行了多次上下文切换,比如应用读取数据时,先在内核态将数据从磁盘读取到内核缓存,再切换到用户态将数据从内核缓存读取到用户缓存。
写入操作也是类似,仅仅是步骤相反,你可以参考下面这张图。
所以这种方式会带来一定的额外开销可能会降低IO效率。
而基于NIO transferTo的实现方式在Linux和Unix上则会使用到零拷贝技术数据传输并不需要用户态参与省去了上下文切换的开销和不必要的内存拷贝进而可能提高应用拷贝性能。注意transferTo不仅仅是可以用在文件拷贝中与其类似的例如读取磁盘文件然后进行Socket发送同样可以享受这种机制带来的性能和扩展性提高。
transferTo的传输过程是
Java IO/NIO源码结构
前面我在典型回答中提了第三种方式即Java标准库也提供了文件拷贝方法java.nio.file.Files.copy。如果你这样回答就一定要小心了因为很少有问题的答案是仅仅调用某个方法。从面试的角度面试官往往会追问既然你提到了标准库那么它是怎么实现的呢有的公司面试官以喜欢追问而出名直到追问到你说不知道。
其实这个问题的答案还真不是那么直观因为实际上有几个不同的copy方法。
public static Path copy(Path source, Path target, CopyOption... options)
throws IOException
public static long copy(InputStream in, Path target, CopyOption... options)
throws IOException
public static long copy(Path source, OutputStream out)
throws IOException
可以看到copy不仅仅是支持文件之间操作没有人限定输入输出流一定是针对文件的这是两个很实用的工具方法。
后面两种copy实现能够在方法实现里直接看到使用的是InputStream.transferTo()你可以直接看源码其内部实现其实是stream在用户态的读写而对于第一种方法的分析过程要相对麻烦一些可以参考下面片段。简单起见我只分析同类型文件系统拷贝过程。
public static Path copy(Path source, Path target, CopyOption... options)
throws IOException
{
FileSystemProvider provider = provider(source);
if (provider(target) == provider) {
// same provider
provider.copy(source, target, options);//这是本文分析的路径
} else {
// different providers
CopyMoveHelper.copyToForeignTarget(source, target, options);
}
return target;
}
我把源码分析过程简单记录如下JDK的源代码中内部实现和公共API定义也不是可以能够简单关联上的NIO部分代码甚至是定义为模板而不是Java源文件在build过程自动生成源码下面顺便介绍一下部分JDK代码机制和如何绕过隐藏障碍。
首先直接跟踪发现FileSystemProvider只是个抽象类阅读它的源码能够理解到原来文件系统实际逻辑存在于JDK内部实现里公共API其实是通过ServiceLoader机制加载一系列文件系统实现然后提供服务。
我们可以在JDK源码里搜索FileSystemProvider和nio可以定位到sun/nio/fs我们知道NIO底层是和操作系统紧密相关的所以每个平台都有自己的部分特有文件系统逻辑。
省略掉一些细节最后我们一步步定位到UnixFileSystemProvider → UnixCopyFile.Transfer发现这是个本地方法。
最后明确定位到UnixCopyFile.c其内部实现清楚说明竟然只是简单的用户态空间拷贝
所以我们明确这个最常见的copy方法其实不是利用transferTo而是本地技术实现的用户态拷贝。
前面谈了不少机制和源码我简单从实践角度总结一下如何提高类似拷贝等IO操作的性能有一些宽泛的原则
在程序中使用缓存等机制合理减少IO次数在网络通信中如TCP传输window大小也可以看作是类似思路
使用transferTo等机制减少上下文切换和额外IO操作。
尽量减少不必要的转换过程,比如编解码;对象序列化和反序列化,比如操作文本文件或者网络通信,如果不是过程中需要使用文本信息,可以考虑不要将二进制信息转换成字符串,直接传输二进制信息。
掌握NIO Buffer
我在上一讲提到Buffer是NIO操作数据的基本工具Java为每种原始数据类型都提供了相应的Buffer实现布尔除外所以掌握和使用Buffer是十分必要的尤其是涉及Direct Buffer等使用因为其在垃圾收集等方面的特殊性更要重点掌握。
Buffer有几个基本属性
capacity它反映这个Buffer到底有多大也就是数组的长度。
position要操作的数据起始位置。
limit相当于操作的限额。在读取或者写入时limit的意义很明显是不一样的。比如读取操作时很可能将limit设置到所容纳数据的上限而在写入时则会设置容量或容量以下的可写限度。
mark记录上一次postion的位置默认是0算是一个便利性的考虑往往不是必须的。
前面三个是我们日常使用最频繁的我简单梳理下Buffer的基本操作
我们创建了一个ByteBuffer准备放入数据capacity当然就是缓冲区大小而position就是0limit默认就是capacity的大小。
当我们写入几个字节的数据时position就会跟着水涨船高但是它不可能超过limit的大小。
如果我们想把前面写入的数据读出来需要调用flip方法将position设置为0limit设置为以前的position那里。
如果还想从头再读一遍可以调用rewind让limit不变position再次设置为0。
更进一步的详细使用,我建议参考相关教程。
Direct Buffer和垃圾收集
我这里重点介绍两种特别的Buffer。
Direct Buffer如果我们看Buffer的方法定义你会发现它定义了isDirect()方法返回当前Buffer是否是Direct类型。这是因为Java提供了堆内和堆外DirectBuffer我们可以以它的allocate或者allocateDirect方法直接创建。
MappedByteBuffer它将文件按照指定大小直接映射为内存区域当程序访问这个内存区域时将直接操作这块儿文件数据省去了将数据从内核空间向用户空间传输的损耗。我们可以使用FileChannel.map创建MappedByteBuffer它本质上也是种Direct Buffer。
在实际使用中Java会尽量对Direct Buffer仅做本地IO操作对于很多大数据量的IO密集操作可能会带来非常大的性能优势因为
Direct Buffer生命周期内内存地址都不会再发生更改进而内核可以安全地对其进行访问很多IO操作会很高效。
减少了堆内对象存储的可能额外维护工作,所以访问效率可能有所提高。
但是请注意Direct Buffer创建和销毁过程中都会比一般的堆内Buffer增加部分开销所以通常都建议用于长期使用、数据较大的场景。
使用Direct Buffer我们需要清楚它对内存和JVM参数的影响。首先因为它不在堆上所以Xmx之类参数其实并不能影响Direct Buffer等堆外成员所使用的内存额度我们可以使用下面参数设置大小
-XX:MaxDirectMemorySize=512M
从参数设置和内存问题排查角度来看这意味着我们在计算Java可以使用的内存大小的时候不能只考虑堆的需要还有Direct Buffer等一系列堆外因素。如果出现内存不足堆外内存占用也是一种可能性。
另外大多数垃圾收集过程中都不会主动收集Direct Buffer它的垃圾收集过程就是基于我在专栏前面所介绍的Cleaner一个内部实现和幻象引用PhantomReference机制其本身不是public类型内部实现了一个Deallocator负责销毁的逻辑。对它的销毁往往要拖到full GC的时候所以使用不当很容易导致OutOfMemoryError。
对于Direct Buffer的回收我有几个建议
在应用程序中显式地调用System.gc()来强制触发。
另外一种思路是在大量使用Direct Buffer的部分框架中框架会自己在程序中调用释放方法Netty就是这么做的有兴趣可以参考其实现PlatformDependent0
重复使用Direct Buffer。
跟踪和诊断Direct Buffer内存占用
因为通常的垃圾收集日志等记录并不包含Direct Buffer等信息所以Direct Buffer内存诊断也是个比较头疼的事情。幸好在JDK 8之后的版本我们可以方便地使用Native Memory TrackingNMT特性来进行诊断你可以在程序启动时加上下面参数
-XX:NativeMemoryTracking={summary|detail}
注意激活NMT通常都会导致JVM出现5%~10%的性能下降,请谨慎考虑。
运行时,可以采用下面命令进行交互式对比:
// 打印NMT信息
jcmd <pid> VM.native_memory detail
// 进行baseline以对比分配内存变化
jcmd <pid> VM.native_memory baseline
// 进行baseline以对比分配内存变化
jcmd <pid> VM.native_memory detail.diff
我们可以在Internal部分发现Direct Buffer内存使用的信息这是因为其底层实际是利用unsafe_allocatememory。严格说这不是JVM内部使用的内存所以在JDK 11以后其实它是归类在other部分里。
JDK 9的输出片段如下“+”表示的就是diff命令发现的分配变化
-Internal (reserved=679KB +4KB, committed=679KB +4KB)
(malloc=615KB +4KB #1571 +4)
(mmap: reserved=64KB, committed=64KB)
注意JVM的堆外内存远不止Direct BufferNMT输出的信息当然也远不止这些我在专栏后面有综合分析更加具体的内存结构的主题。
今天我分析了Java IO/NIO底层文件操作数据的机制以及如何实现零拷贝的高性能操作梳理了Buffer的使用和类型并针对Direct Buffer的生命周期管理和诊断进行了较详细的分析。
一课一练
关于今天我们讨论的题目你做到心中有数了吗你可以思考下如果我们需要在channel读取的过程中将不同片段写入到相应的Buffer里面类似二进制消息分拆成消息头、消息体等可以采用NIO的什么机制做到呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,191 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 谈谈接口和抽象类有什么区别?-极客时间
Java 是非常典型的面向对象语言,曾经有一段时间,程序员整天把面向对象、设计模式挂在嘴边。虽然如今大家对这方面已经不再那么狂热,但是不可否认,掌握面向对象设计原则和技巧,是保证高质量代码的基础之一。
面向对象提供的基本机制,对于提高开发、沟通等各方面效率至关重要。考察面向对象也是面试中的常见一环,下面我来聊聊面向对象设计基础。
今天我要问你的问题是,谈谈接口和抽象类有什么区别?
典型回答
接口和抽象类是 Java 面向对象设计的两个基础机制。
接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到 API 定义和实现分离的目的。接口,不能实例化;不能包含任何非常量成员,任何 field 都是隐含着 public static final 的意义同时没有非静态方法实现也就是说要么是抽象方法要么是静态方法。Java 标准类库中,定义了非常多的接口,比如 java.util.List。
抽象类是不能实例化的类,用 abstract 关键字修饰 class其目的主要是代码重用。除了不能实例化形式上和一般的 Java 类并没有太大区别,可以有一个或者多个抽象方法,也可以没有抽象方法。抽象类大多用于抽取相关 Java 类的共用方法实现或者是共同成员变量然后通过继承的方式达到代码复用的目的。Java 标准库中,比如 collection 框架,很多通用部分就被抽取成为抽象类,例如 java.util.AbstractList。
Java 类实现 interface 使用 implements 关键词,继承 abstract class 则是使用 extends 关键词,我们可以参考 Java 标准库中的 ArrayList。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
//...
}
考点分析
这是个非常高频的 Java 面向对象基础问题,看起来非常简单的问题,如果面试官稍微深入一些,你会发现很多有意思的地方,可以从不同角度全面地考察你对基本机制的理解和掌握。比如:
对于 Java 的基本元素的语法是否理解准确。能否定义出语法基本正确的接口、抽象类或者相关继承实现涉及重载Overload、重写Override更是有各种不同的题目。
在软件设计开发中妥善地使用接口和抽象类。你至少知道典型应用场景,掌握基础类库重要接口的使用;掌握设计方法,能够在 review 代码的时候看出明显的不利于未来维护的设计。
掌握 Java 语言特性演进。现在非常多的框架已经是基于 Java 8并逐渐支持更新版本掌握相关语法理解设计目的是很有必要的。
知识扩展
我会从接口、抽象类的一些实践,以及语言变化方面去阐述一些扩展知识点。
Java 相比于其他面向对象语言,如 C++,设计上有一些基本区别,比如 Java 不支持多继承。这种限制在规范了代码实现的同时也产生了一些局限性影响着程序设计结构。Java 类可以实现多个接口,因为接口是抽象方法的集合,所以这是声明性的,但不能通过扩展多个抽象类来重用逻辑。
在一些情况下存在特定场景需要抽象出与具体实现、实例化无关的通用逻辑或者纯调用关系的逻辑但是使用传统的抽象类会陷入到单继承的窘境。以往常见的做法是实现由静态方法组成的工具类Utils比如 java.util.Collections。
设想,为接口添加任何抽象方法,相应的所有实现了这个接口的类,也必须实现新增方法,否则会出现编译错误。对于抽象类,如果我们添加非抽象方法,其子类只会享受到能力扩展,而不用担心编译出问题。
接口的职责也不仅仅限于抽象方法的集合,其实有各种不同的实践。有一类没有任何方法的接口,通常叫作 Marker Interface顾名思义它的目的就是为了声明某些东西比如我们熟知的 Cloneable、Serializable 等。这种用法,也存在于业界其他的 Java 产品代码中。
从表面看,这似乎和 Annotation 异曲同工,也确实如此,它的好处是简单直接。对于 Annotation因为可以指定参数和值在表达能力上要更强大一些所以更多人选择使用 Annotation。
Java 8 增加了函数式编程的支持,所以又增加了一类定义,即所谓 functional interface简单说就是只有一个抽象方法的接口通常建议使用 @FunctionalInterface Annotation 来标记。Lambda 表达式本身可以看作是一类 functional interface某种程度上这和面向对象可以算是两码事。我们熟知的 Runnable、Callable 之类,都是 functional interface这里不再多介绍了有兴趣你可以参考https://www.oreilly.com/learning/java-8-functional-interfaces
还有一点可能让人感到意外严格说Java 8 以后,接口也是可以有方法实现的!
从 Java 8 开始interface 增加了对 default method 的支持。Java 9 以后,甚至可以定义 private default method。Default method 提供了一种二进制兼容的扩展已有接口的办法。比如,我们熟知的 java.util.Collection它是 collection 体系的 root interface在 Java 8 中添加了一系列 default method主要是增加 Lambda、Stream 相关的功能。我在专栏前面提到的类似 Collections 之类的工具类,很多方法都适合作为 default method 实现在基础接口里面。
你可以参考下面代码片段:
public interface Collection<E> extends Iterable<E> {
/**
* Returns a sequential Stream with this collection as its source
* ...
**/
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
}
面向对象设计
谈到面向对象,很多人就会想起设计模式,那些是非常经典的问题和设计方法的总结。我今天来夯实一下基础,先来聊聊面向对象设计的基本方面。
我们一定要清楚面向对象的基本要素:封装、继承、多态。
封装的目的是隐藏事务内部的实现细节,以便提高安全性和简化编程。封装提供了合理的边界,避免外部调用者接触到内部的细节。我们在日常开发中,因为无意间暴露了细节导致的难缠 bug 太多了,比如在多线程环境暴露内部状态,导致的并发修改问题。从另外一个角度看,封装这种隐藏,也提供了简化的界面,避免太多无意义的细节浪费调用者的精力。
继承是代码复用的基础机制,类似于我们对于马、白马、黑马的归纳总结。但要注意,继承可以看作是非常紧耦合的一种关系,父类代码修改,子类行为也会变动。在实践中,过度滥用继承,可能会起到反效果。
多态你可能立即会想到重写override和重载overload、向上转型。简单说重写是父子类中相同名字和参数的方法不同的实现重载则是相同名字的方法但是不同的参数本质上这些方法签名是不一样的为了更好说明请参考下面的样例代码
public int doSomething() {
return 0;
}
// 输入参数不同,意味着方法签名不同,重载的体现
public int doSomething(List<String> strs) {
return 0;
}
// return类型不一样编译不能通过
public short doSomething() {
return 0;
}
这里你可以思考一个小问题,方法名称和参数一致,但是返回值不同,这种情况在 Java 代码中算是有效的重载吗? 答案是不是的,编译都会出错的。
进行面向对象编程,掌握基本的设计原则是必须的,我今天介绍最通用的部分,也就是所谓的 S.O.L.I.D 原则。
单一职责Single Responsibility类或者对象最好是只有单一职责在程序设计中如果发现某个类承担着多种义务可以考虑进行拆分。
开关原则Open-Close, Open for extension, close for modification设计要对扩展开放对修改关闭。换句话说程序设计应保证平滑的扩展性尽量避免因为新增同类功能而修改已有实现这样可以少产出些回归regression问题。
里氏替换Liskov Substitution这是面向对象的基本要素之一进行继承关系抽象时凡是可以用父类或者基类的地方都可以用子类替换。
接口分离Interface Segregation我们在进行类和接口设计时如果在一个接口里定义了太多方法其子类很可能面临两难就是只有部分方法对它是有意义的这就破坏了程序的内聚性。
对于这种情况,可以通过拆分成功能单一的多个接口,将行为进行解耦。在未来维护中,如果某个接口设计有变,不会对使用其他接口的子类构成影响。
依赖反转Dependency Inversion实体应该依赖于抽象而不是实现。也就是说高层次模块不应该依赖于低层次模块而是应该基于抽象。实践这一原则是保证产品代码之间适当耦合度的法宝。
OOP 原则实践中的取舍
值得注意的是现代语言的发展很多时候并不是完全遵守前面的原则的比如Java 10 中引入了本地方法类型推断和 var 类型。按照,里氏替换原则,我们通常这样定义变量:
List<String> list = new ArrayList<>();
如果使用 var 类型,可以简化为
var list = new ArrayList<String>();
但是list 实际会被推断为“ArrayList < String >
ArrayList<String> list = new ArrayList<String>();
理论上,这种语法上的便利,其实是增强了程序对实现的依赖,但是微小的类型泄漏却带来了书写的便利和代码可读性的提高,所以,实践中我们还是要按照得失利弊进行选择,而不是一味得遵循原则。
OOP 原则在面试题目中的分析
我在以往面试中发现即使是有多年编程经验的工程师也还没有真正掌握面向对象设计的基本的原则如开关原则Open-Close。看看下面这段代码改编自朋友圈盛传的某伟大公司产品代码你觉得可以利用面向对象设计原则如何改进
public class VIPCenter {
void serviceVIP(T extend User user>) {
if (user instanceof SlumDogVIP) {
// 穷X VIP活动抢的那种
// do somthing
} else if(user instanceof RealVIP) {
// do somthing
}
// ...
}
这段代码的一个问题是,业务逻辑集中在一起,当出现新的用户类型时,比如,大数据发现了我们是肥羊,需要去收获一下, 这就需要直接去修改服务方法代码实现,这可能会意外影响不相关的某个用户类型逻辑。
利用开关原则,我们可以尝试改造为下面的代码:
public class VIPCenter {
private Map<User.TYPE, ServiceProvider> providers;
void serviceVIP(T extend User user {
providers.get(user.getType()).service(user);
}
}
interface ServiceProvider{
void service(T extend User user) ;
}
class SlumDogVIPServiceProvider implements ServiceProvider{
void service(T extend User user){
// do somthing
}
}
class RealVIPServiceProvider implements ServiceProvider{
void service(T extend User user) {
// do something
}
}
上面的示例,将不同对象分类的服务方法进行抽象,把业务逻辑的紧耦合关系拆开,实现代码的隔离保证了方便的扩展。
今天我对 Java 面向对象技术进行了梳理,对比了抽象类和接口,分析了 Java 语言在接口层面的演进和相应程序设计实现,最后回顾并实践了面向对象设计的基本原则,希望对你有所帮助。
一课一练
关于接口和抽象类的区别,你做到心中有数了吗?给你布置一个思考题,思考一下自己的产品代码,有没有什么地方违反了基本设计原则?那些一改就崩的代码,是否遵循了开关原则?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,191 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 谈谈接口和抽象类有什么区别?
Java是非常典型的面向对象语言曾经有一段时间程序员整天把面向对象、设计模式挂在嘴边。虽然如今大家对这方面已经不再那么狂热但是不可否认掌握面向对象设计原则和技巧是保证高质量代码的基础之一。
面向对象提供的基本机制,对于提高开发、沟通等各方面效率至关重要。考察面向对象也是面试中的常见一环,下面我来聊聊面向对象设计基础。
今天我要问你的问题是,谈谈接口和抽象类有什么区别?
典型回答
接口和抽象类是Java面向对象设计的两个基础机制。
接口是对行为的抽象它是抽象方法的集合利用接口可以达到API定义和实现分离的目的。接口不能实例化不能包含任何非常量成员任何field都是隐含着public static final的意义同时没有非静态方法实现也就是说要么是抽象方法要么是静态方法。Java标准类库中定义了非常多的接口比如java.util.List。
抽象类是不能实例化的类用abstract关键字修饰class其目的主要是代码重用。除了不能实例化形式上和一般的Java类并没有太大区别可以有一个或者多个抽象方法也可以没有抽象方法。抽象类大多用于抽取相关Java类的共用方法实现或者是共同成员变量然后通过继承的方式达到代码复用的目的。Java标准库中比如collection框架很多通用部分就被抽取成为抽象类例如java.util.AbstractList。
Java类实现interface使用implements关键词继承abstract class则是使用extends关键词我们可以参考Java标准库中的ArrayList。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
//...
}
考点分析
这是个非常高频的Java面向对象基础问题看起来非常简单的问题如果面试官稍微深入一些你会发现很多有意思的地方可以从不同角度全面地考察你对基本机制的理解和掌握。比如:
对于Java的基本元素的语法是否理解准确。能否定义出语法基本正确的接口、抽象类或者相关继承实现涉及重载Overload、重写Override更是有各种不同的题目。
在软件设计开发中妥善地使用接口和抽象类。你至少知道典型应用场景掌握基础类库重要接口的使用掌握设计方法能够在review代码的时候看出明显的不利于未来维护的设计。
掌握Java语言特性演进。现在非常多的框架已经是基于Java 8并逐渐支持更新版本掌握相关语法理解设计目的是很有必要的。
知识扩展
我会从接口、抽象类的一些实践,以及语言变化方面去阐述一些扩展知识点。
Java相比于其他面向对象语言如C++设计上有一些基本区别比如Java不支持多继承。这种限制在规范了代码实现的同时也产生了一些局限性影响着程序设计结构。Java类可以实现多个接口因为接口是抽象方法的集合所以这是声明性的但不能通过扩展多个抽象类来重用逻辑。
在一些情况下存在特定场景需要抽象出与具体实现、实例化无关的通用逻辑或者纯调用关系的逻辑但是使用传统的抽象类会陷入到单继承的窘境。以往常见的做法是实现由静态方法组成的工具类Utils比如java.util.Collections。
设想,为接口添加任何抽象方法,相应的所有实现了这个接口的类,也必须实现新增方法,否则会出现编译错误。对于抽象类,如果我们添加非抽象方法,其子类只会享受到能力扩展,而不用担心编译出问题。
接口的职责也不仅仅限于抽象方法的集合其实有各种不同的实践。有一类没有任何方法的接口通常叫作Marker Interface顾名思义它的目的就是为了声明某些东西比如我们熟知的Cloneable、Serializable等。这种用法也存在于业界其他的Java产品代码中。
从表面看这似乎和Annotation异曲同工也确实如此它的好处是简单直接。对于Annotation因为可以指定参数和值在表达能力上要更强大一些所以更多人选择使用Annotation。
Java 8增加了函数式编程的支持所以又增加了一类定义即所谓functional interface简单说就是只有一个抽象方法的接口通常建议使用@FunctionalInterface Annotation来标记。Lambda表达式本身可以看作是一类functional interface某种程度上这和面向对象可以算是两码事。我们熟知的Runnable、Callable之类都是functional interface这里不再多介绍了有兴趣你可以参考https://www.oreilly.com/learning/java-8-functional-interfaces 。
还有一点可能让人感到意外严格说Java 8以后接口也是可以有方法实现的
从Java 8开始interface增加了对default method的支持。Java 9以后甚至可以定义private default method。Default method提供了一种二进制兼容的扩展已有接口的办法。比如我们熟知的java.util.Collection它是collection体系的root interface在Java 8中添加了一系列default method主要是增加Lambda、Stream相关的功能。我在专栏前面提到的类似Collections之类的工具类很多方法都适合作为default method实现在基础接口里面。
你可以参考下面代码片段:
public interface Collection<E> extends Iterable<E> {
/**
* Returns a sequential Stream with this collection as its source
* ...
**/
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
}
面向对象设计
谈到面向对象,很多人就会想起设计模式,那些是非常经典的问题和设计方法的总结。我今天来夯实一下基础,先来聊聊面向对象设计的基本方面。
我们一定要清楚面向对象的基本要素:封装、继承、多态。
封装的目的是隐藏事务内部的实现细节以便提高安全性和简化编程。封装提供了合理的边界避免外部调用者接触到内部的细节。我们在日常开发中因为无意间暴露了细节导致的难缠bug太多了比如在多线程环境暴露内部状态导致的并发修改问题。从另外一个角度看封装这种隐藏也提供了简化的界面避免太多无意义的细节浪费调用者的精力。
继承是代码复用的基础机制,类似于我们对于马、白马、黑马的归纳总结。但要注意,继承可以看作是非常紧耦合的一种关系,父类代码修改,子类行为也会变动。在实践中,过度滥用继承,可能会起到反效果。
多态你可能立即会想到重写override和重载overload、向上转型。简单说重写是父子类中相同名字和参数的方法不同的实现重载则是相同名字的方法但是不同的参数本质上这些方法签名是不一样的为了更好说明请参考下面的样例代码
public int doSomething() {
return 0;
}
// 输入参数不同,意味着方法签名不同,重载的体现
public int doSomething(List<String> strs) {
return 0;
}
// return类型不一样编译不能通过
public short doSomething() {
return 0;
}
这里你可以思考一个小问题方法名称和参数一致但是返回值不同这种情况在Java代码中算是有效的重载吗 答案是不是的,编译都会出错的。
进行面向对象编程掌握基本的设计原则是必须的我今天介绍最通用的部分也就是所谓的S.O.L.I.D原则。
单一职责Single Responsibility类或者对象最好是只有单一职责在程序设计中如果发现某个类承担着多种义务可以考虑进行拆分。
开关原则Open-Close, Open for extension, close for modification设计要对扩展开放对修改关闭。换句话说程序设计应保证平滑的扩展性尽量避免因为新增同类功能而修改已有实现这样可以少产出些回归regression问题。
里氏替换Liskov Substitution这是面向对象的基本要素之一进行继承关系抽象时凡是可以用父类或者基类的地方都可以用子类替换。
接口分离Interface Segregation我们在进行类和接口设计时如果在一个接口里定义了太多方法其子类很可能面临两难就是只有部分方法对它是有意义的这就破坏了程序的内聚性。-
对于这种情况,可以通过拆分成功能单一的多个接口,将行为进行解耦。在未来维护中,如果某个接口设计有变,不会对使用其他接口的子类构成影响。
依赖反转Dependency Inversion实体应该依赖于抽象而不是实现。也就是说高层次模块不应该依赖于低层次模块而是应该基于抽象。实践这一原则是保证产品代码之间适当耦合度的法宝。
OOP原则实践中的取舍
值得注意的是现代语言的发展很多时候并不是完全遵守前面的原则的比如Java 10中引入了本地方法类型推断和var类型。按照里氏替换原则我们通常这样定义变量
List<String> list = new ArrayList<>();
如果使用var类型可以简化为
var list = new ArrayList<String>();
但是list实际会被推断为“ArrayList < String >
ArrayList<String> list = new ArrayList<String>();
理论上,这种语法上的便利,其实是增强了程序对实现的依赖,但是微小的类型泄漏却带来了书写的便利和代码可读性的提高,所以,实践中我们还是要按照得失利弊进行选择,而不是一味得遵循原则。
OOP原则在面试题目中的分析
我在以往面试中发现即使是有多年编程经验的工程师也还没有真正掌握面向对象设计的基本的原则如开关原则Open-Close。看看下面这段代码改编自朋友圈盛传的某伟大公司产品代码你觉得可以利用面向对象设计原则如何改进
public class VIPCenter {
void serviceVIP(T extend User user>) {
if (user instanceof SlumDogVIP) {
// 穷X VIP活动抢的那种
// do somthing
} else if(user instanceof RealVIP) {
// do somthing
}
// ...
}
这段代码的一个问题是,业务逻辑集中在一起,当出现新的用户类型时,比如,大数据发现了我们是肥羊,需要去收获一下, 这就需要直接去修改服务方法代码实现,这可能会意外影响不相关的某个用户类型逻辑。
利用开关原则,我们可以尝试改造为下面的代码:
public class VIPCenter {
private Map<User.TYPE, ServiceProvider> providers;
void serviceVIP(T extend User user {
providers.get(user.getType()).service(user);
}
}
interface ServiceProvider{
void service(T extend User user) ;
}
class SlumDogVIPServiceProvider implements ServiceProvider{
void service(T extend User user){
// do somthing
}
}
class RealVIPServiceProvider implements ServiceProvider{
void service(T extend User user) {
// do something
}
}
上面的示例,将不同对象分类的服务方法进行抽象,把业务逻辑的紧耦合关系拆开,实现代码的隔离保证了方便的扩展。
今天我对Java面向对象技术进行了梳理对比了抽象类和接口分析了Java语言在接口层面的演进和相应程序设计实现最后回顾并实践了面向对象设计的基本原则希望对你有所帮助。
一课一练
关于接口和抽象类的区别,你做到心中有数了吗?给你布置一个思考题,思考一下自己的产品代码,有没有什么地方违反了基本设计原则?那些一改就崩的代码,是否遵循了开关原则?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,182 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 谈谈你知道的设计模式?-极客时间
设计模式是人们为软件开发中相同表征的问题,抽象出的可重复利用的解决方案。在某种程度上,设计模式已经代表了一些特定情况的最佳实践,同时也起到了软件工程师之间沟通的“行话”的作用。理解和掌握典型的设计模式,有利于我们提高沟通、设计的效率和质量。
今天我要问你的问题是谈谈你知道的设计模式请手动实现单例模式Spring 等框架中使用了哪些模式?
典型回答
大致按照模式的应用目标分类,设计模式可以分为创建型模式、结构型模式和行为型模式。
创建型模式是对对象创建过程的各种问题和解决方案的总结包括各种工厂模式Factory、Abstract Factory、单例模式Singleton、构建器模式Builder、原型模式ProtoType
结构型模式是针对软件设计结构的总结关注于类、对象继承、组合方式的实践经验。常见的结构型模式包括桥接模式Bridge、适配器模式Adapter、装饰者模式Decorator、代理模式Proxy、组合模式Composite、外观模式Facade、享元模式Flyweight等。
行为型模式是从类或对象之间交互、职责划分等角度总结的模式。比较常见的行为型模式有策略模式Strategy、解释器模式Interpreter、命令模式Command、观察者模式Observer、迭代器模式Iterator、模板方法模式Template Method、访问者模式Visitor
考点分析
我建议可以在回答时适当地举些例子,更加清晰地说明典型模式到底是什么样子,典型使用场景是怎样的。这里举个 Java 基础类库中的例子供你参考。https://en.wikipedia.org/wiki/Design_Patterns。
首先,【专栏第 11 讲】刚介绍过 IO 框架,我们知道 InputStream 是一个抽象类,标准类库中提供了 FileInputStream、ByteArrayInputStream 等各种不同的子类,分别从不同角度对 InputStream 进行了功能扩展,这是典型的装饰器模式应用案例。
识别装饰器模式,可以通过识别类设计特征来进行判断,也就是其类构造函数以相同的抽象类或者接口为输入参数。
因为装饰器模式本质上是包装同类型实例,我们对目标对象的调用,往往会通过包装类覆盖过的方法,迂回调用被包装的实例,这就可以很自然地实现增加额外逻辑的目的,也就是所谓的“装饰”。
例如BufferedInputStream 经过包装,为输入流过程增加缓存,类似这种装饰器还可以多次嵌套,不断地增加不同层次的功能。
public BufferedInputStream(InputStream in)
我在下面的类图里,简单总结了 InputStream 的装饰模式实践。
接下来再看第二个例子。创建型模式尤其是工厂模式,在我们的代码中随处可见,我举个相对不同的 API 设计实践。比如JDK 最新版本中 HTTP/2 Client API下面这个创建 HttpRequest 的过程就是典型的构建器模式Builder通常会被实现成fluent 风格的 API也有人叫它方法链。
HttpRequest request = HttpRequest.newBuilder(new URI(uri))
.header(headerAlice, valueAlice)
.headers(headerBob, value1Bob,
headerCarl, valueCarl,
headerBob, value2Bob)
.GET()
.build();
使用构建器模式,可以比较优雅地解决构建复杂对象的麻烦,这里的“复杂”是指类似需要输入的参数组合较多,如果用构造函数,我们往往需要为每一种可能的输入参数组合实现相应的构造函数,一系列复杂的构造函数会让代码阅读性和可维护性变得很差。
上面的分析也进一步反映了创建型模式的初衷,即,将对象创建过程单独抽象出来,从结构上把对象使用逻辑和创建逻辑相互独立,隐藏对象实例的细节,进而为使用者实现了更加规范、统一的逻辑。
更进一步进行设计模式考察,面试官可能会:
希望你写一个典型的设计模式实现。这虽然看似简单,但即使是最简单的单例,也能够综合考察代码基本功。
考察典型的设计模式使用,尤其是结合标准库或者主流开源框架,考察你对业界良好实践的掌握程度。
在面试时如果恰好问到你不熟悉的模式,你可以稍微引导一下,比如介绍你在产品中使用了什么自己相对熟悉的模式,试图解决什么问题,它们的优点和缺点等。
下面,我会针对前面两点,结合代码实例进行分析。
知识扩展
我们来实现一个日常非常熟悉的单例设计模式。看起来似乎很简单,那么下面这个样例符合基本需求吗?
public class Singleton {
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}
是不是总感觉缺了点什么原来Java 会自动为没有明确声明构造函数的类,定义一个 public 的无参数的构造函数所以上面的例子并不能保证额外的对象不被创建出来别人完全可以直接“new Singleton()”,那我们应该怎么处理呢?
不错,可以为单例定义一个 private 的构造函数(也有建议声明为枚举,这是有争议的,我个人不建议选择相对复杂的枚举,毕竟日常开发不是学术研究)。这样还有什么改进的余地吗?
【专栏第 10 讲】介绍 ConcurrentHashMap 时提到过标准类库中很多地方使用懒加载lazy-load改善初始内存开销单例同样适用下面是修正后的改进版本。
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
这个实现在单线程环境不存在问题,但是如果处于并发场景,就需要考虑线程安全,最熟悉的就莫过于“双检锁”,其要点在于:
这里的 volatile 能够提供可见性,以及保证 getInstance 返回的是初始化完全的对象。
在同步之前进行 null 检查,以尽量避免进入相对昂贵的同步块。
直接在 class 级别进行同步,保证线程安全的类方法调用。
public class Singleton {
private static volatile Singleton singleton = null;
private Singleton() {
}
public static Singleton getSingleton() {
if (singleton == null) { // 尽量避免重复进入同步块
synchronized (Singleton.class) { // 同步.class意味着对同步类方法调用
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
在这段代码中,争论较多的是 volatile 修饰静态变量,当 Singleton 类本身有多个成员变量时,需要保证初始化过程完成后,才能被 get 到。
在现代 Java 中内存排序模型JMM已经非常完善通过 volatile 的 write 或者 read能保证所谓的 happen-before也就是避免常被提到的指令重排。换句话说构造对象的 store 指令能够被保证一定在 volatile read 之前。
当然也有一些人推荐利用内部类持有静态对象的方式实现其理论依据是对象初始化过程中隐含的初始化锁有兴趣的话你可以参考jls-12.4.2 中对 LC 的说明),这种和前面的双检锁实现都能保证线程安全,不过语法稍显晦涩,未必有特别的优势。
public class Singleton {
private Singleton(){}
public static Singleton getSingleton(){
return Holder.singleton;
}
private static class Holder {
private static Singleton singleton = new Singleton();
}
}
所以,可以看出,即使是看似最简单的单例模式,在增加各种高标准需求之后,同样需要非常多的实现考量。
上面是比较学究的考察,其实实践中未必需要如此复杂,如果我们看 Java 核心类库自己的单例实现比如java.lang.Runtime你会发现
它并没使用复杂的双检锁之类。
静态实例被声明为 final这是被通常实践忽略的一定程度保证了实例不被篡改【专栏第 6 讲】介绍过,反射之类可以绕过私有访问限制),也有有限的保证执行顺序的语义。
private static final Runtime currentRuntime = new Runtime();
private static Version version;
// …
public static Runtime getRuntime() {
return currentRuntime;
}
/** Don't let anyone else instantiate this class */
private Runtime() {}
前面说了不少代码实践,下面一起来简要看看主流开源框架,如 Spring 等如何在 API 设计中使用设计模式。你至少要有个大体的印象,如:
BeanFactory和ApplicationContext应用了工厂模式。
在 Bean 的创建中Spring 也为不同 scope 定义的对象,提供了单例和原型等模式实现。
我在【专栏第 6 讲】介绍的 AOP 领域则是使用了代理模式、装饰器模式、适配器模式等。
各种事件监听器,是观察者模式的典型应用。
类似 JdbcTemplate 等则是应用了模板模式。
今天,我与你回顾了设计模式的分类和主要类型,并从 Java 核心类库、开源框架等不同角度分析了其采用的模式,并结合单例的不同实现,分析了如何实现符合线程安全等需求的单例,希望可以对你的工程实践有所帮助。另外,我想最后补充的是,设计模式也不是银弹,要避免滥用或者过度设计。
一课一练
关于设计模式你做到心中有数了吗?你可以思考下,在业务代码中,经常发现大量 XXFacade外观模式是解决什么问题适用于什么场景
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,194 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 谈谈你知道的设计模式?
设计模式是人们为软件开发中相同表征的问题,抽象出的可重复利用的解决方案。在某种程度上,设计模式已经代表了一些特定情况的最佳实践,同时也起到了软件工程师之间沟通的“行话”的作用。理解和掌握典型的设计模式,有利于我们提高沟通、设计的效率和质量。
今天我要问你的问题是谈谈你知道的设计模式请手动实现单例模式Spring等框架中使用了哪些模式
典型回答
大致按照模式的应用目标分类,设计模式可以分为创建型模式、结构型模式和行为型模式。
创建型模式是对对象创建过程的各种问题和解决方案的总结包括各种工厂模式Factory、Abstract Factory、单例模式Singleton、构建器模式Builder、原型模式ProtoType
结构型模式是针对软件设计结构的总结关注于类、对象继承、组合方式的实践经验。常见的结构型模式包括桥接模式Bridge、适配器模式Adapter、装饰者模式Decorator、代理模式Proxy、组合模式Composite、外观模式Facade、享元模式Flyweight等。
行为型模式是从类或对象之间交互、职责划分等角度总结的模式。比较常见的行为型模式有策略模式Strategy、解释器模式Interpreter、命令模式Command、观察者模式Observer、迭代器模式Iterator、模板方法模式Template Method、访问者模式Visitor
考点分析
这个问题主要是考察你对设计模式的了解和掌握程度更多相关内容你可以参考https://en.wikipedia.org/wiki/Design_Patterns。
我建议可以在回答时适当地举些例子更加清晰地说明典型模式到底是什么样子典型使用场景是怎样的。这里举个Java基础类库中的例子供你参考。
首先,[专栏第11讲]刚介绍过IO框架我们知道InputStream是一个抽象类标准类库中提供了FileInputStream、ByteArrayInputStream等各种不同的子类分别从不同角度对InputStream进行了功能扩展这是典型的装饰器模式应用案例。
识别装饰器模式,可以通过识别类设计特征来进行判断,也就是其类构造函数以相同的抽象类或者接口为输入参数。
因为装饰器模式本质上是包装同类型实例,我们对目标对象的调用,往往会通过包装类覆盖过的方法,迂回调用被包装的实例,这就可以很自然地实现增加额外逻辑的目的,也就是所谓的“装饰”。
例如BufferedInputStream经过包装为输入流过程增加缓存类似这种装饰器还可以多次嵌套不断地增加不同层次的功能。
public BufferedInputStream(InputStream in)
我在下面的类图里简单总结了InputStream的装饰模式实践。
接下来再看第二个例子。创建型模式尤其是工厂模式在我们的代码中随处可见我举个相对不同的API设计实践。比如JDK最新版本中 HTTP/2 Client API下面这个创建HttpRequest的过程就是典型的构建器模式Builder通常会被实现成fluent风格的API也有人叫它方法链。
HttpRequest request = HttpRequest.newBuilder(new URI(uri))
.header(headerAlice, valueAlice)
.headers(headerBob, value1Bob,
headerCarl, valueCarl,
headerBob, value2Bob)
.GET()
.build();
使用构建器模式,可以比较优雅地解决构建复杂对象的麻烦,这里的“复杂”是指类似需要输入的参数组合较多,如果用构造函数,我们往往需要为每一种可能的输入参数组合实现相应的构造函数,一系列复杂的构造函数会让代码阅读性和可维护性变得很差。
上面的分析也进一步反映了创建型模式的初衷,即,将对象创建过程单独抽象出来,从结构上把对象使用逻辑和创建逻辑相互独立,隐藏对象实例的细节,进而为使用者实现了更加规范、统一的逻辑。
更进一步进行设计模式考察,面试官可能会:
希望你写一个典型的设计模式实现。这虽然看似简单,但即使是最简单的单例,也能够综合考察代码基本功。
考察典型的设计模式使用,尤其是结合标准库或者主流开源框架,考察你对业界良好实践的掌握程度。
在面试时如果恰好问到你不熟悉的模式,你可以稍微引导一下,比如介绍你在产品中使用了什么自己相对熟悉的模式,试图解决什么问题,它们的优点和缺点等。
下面,我会针对前面两点,结合代码实例进行分析。
知识扩展
我们来实现一个日常非常熟悉的单例设计模式。看起来似乎很简单,那么下面这个样例符合基本需求吗?
public class Singleton {
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}
是不是总感觉缺了点什么原来Java会自动为没有明确声明构造函数的类定义一个public的无参数的构造函数所以上面的例子并不能保证额外的对象不被创建出来别人完全可以直接“new Singleton()”,那我们应该怎么处理呢?
不错可以为单例定义一个private的构造函数也有建议声明为枚举这是有争议的我个人不建议选择相对复杂的枚举毕竟日常开发不是学术研究。这样还有什么改进的余地吗
[专栏第10讲]介绍ConcurrentHashMap时提到过标准类库中很多地方使用懒加载lazy-load改善初始内存开销单例同样适用下面是修正后的改进版本。
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
这个实现在单线程环境不存在问题,但是如果处于并发场景,就需要考虑线程安全,最熟悉的就莫过于“双检锁”,其要点在于:
这里的volatile能够提供可见性以及保证getInstance返回的是初始化完全的对象。
在同步之前进行null检查以尽量避免进入相对昂贵的同步块。
直接在class级别进行同步保证线程安全的类方法调用。
public class Singleton {
private static volatile Singleton singleton = null;
private Singleton() {
}
public static Singleton getSingleton() {
if (singleton == null) { // 尽量避免重复进入同步块
synchronized (Singleton.class) { // 同步.class意味着对同步类方法调用
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
在这段代码中争论较多的是volatile修饰静态变量当Singleton类本身有多个成员变量时需要保证初始化过程完成后才能被get到。
在现代Java中内存排序模型JMM已经非常完善通过volatile的write或者read能保证所谓的happen-before也就是避免常被提到的指令重排。换句话说构造对象的store指令能够被保证一定在volatile read之前。
当然也有一些人推荐利用内部类持有静态对象的方式实现其理论依据是对象初始化过程中隐含的初始化锁有兴趣的话你可以参考jls-12.4.2 中对LC的说明这种和前面的双检锁实现都能保证线程安全不过语法稍显晦涩未必有特别的优势。
public class Singleton {
private Singleton(){}
public static Singleton getSingleton(){
return Holder.singleton;
}
private static class Holder {
private static Singleton singleton = new Singleton();
}
}
所以,可以看出,即使是看似最简单的单例模式,在增加各种高标准需求之后,同样需要非常多的实现考量。
上面是比较学究的考察其实实践中未必需要如此复杂如果我们看Java核心类库自己的单例实现比如java.lang.Runtime你会发现
它并没使用复杂的双检锁之类。
静态实例被声明为final这是被通常实践忽略的一定程度保证了实例不被篡改[专栏第6讲]介绍过,反射之类可以绕过私有访问限制),也有有限的保证执行顺序的语义。
private static final Runtime currentRuntime = new Runtime();
private static Version version;
// …
public static Runtime getRuntime() {
return currentRuntime;
}
/** Don't let anyone else instantiate this class */
private Runtime() {}
前面说了不少代码实践下面一起来简要看看主流开源框架如Spring等如何在API设计中使用设计模式。你至少要有个大体的印象
BeanFactory和ApplicationContext应用了工厂模式。
在Bean的创建中Spring也为不同scope定义的对象提供了单例和原型等模式实现。
我在[专栏第6讲]介绍的AOP领域则是使用了代理模式、装饰器模式、适配器模式等。
各种事件监听器,是观察者模式的典型应用。
类似JdbcTemplate等则是应用了模板模式。
今天我与你回顾了设计模式的分类和主要类型并从Java核心类库、开源框架等不同角度分析了其采用的模式并结合单例的不同实现分析了如何实现符合线程安全等需求的单例希望可以对你的工程实践有所帮助。另外我想最后补充的是设计模式也不是银弹要避免滥用或者过度设计。
一课一练
关于设计模式你做到心中有数了吗你可以思考下在业务代码中经常发现大量XXFacade外观模式是解决什么问题适用于什么场景
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,235 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 synchronized和ReentrantLock有什么区别呢-极客时间
从今天开始,我们将进入 Java 并发学习阶段。软件并发已经成为现代软件开发的基础能力,而 Java 精心设计的高效并发机制,正是构建大规模应用的基础之一,所以考察并发基本功也成为各个公司面试 Java 工程师的必选项。
今天我要问你的问题是,** synchronized 和 ReentrantLock 有什么区别?有人说 synchronized 最慢,这话靠谱吗?**
典型回答
synchronized 是 Java 内建的同步机制,所以也有人称其为 Intrinsic Locking它提供了互斥的语义和可见性当一个线程已经获取当前锁时其他试图获取的线程只能等待或者阻塞在那里。
在 Java 5 以前synchronized 是仅有的同步手段,在代码中, synchronized 可以用来修饰方法,也可以使用在特定的代码块儿上,本质上 synchronized 方法等同于把方法全部语句用 synchronized 块包起来。
ReentrantLock通常翻译为再入锁是 Java 5 提供的锁实现,它的语义和 synchronized 基本相同。再入锁通过代码直接调用 lock() 方法获取代码书写也更加灵活。与此同时ReentrantLock 提供了很多实用的方法,能够实现很多 synchronized 无法做到的细节控制,比如可以控制 fairness也就是公平性或者利用定义条件等。但是编码中也需要注意必须要明确调用 unlock() 方法释放,不然就会一直持有该锁。
synchronized 和 ReentrantLock 的性能不能一概而论,早期版本 synchronized 在很多场景下性能相差较大,在后续版本进行了较多改进,在低竞争场景中表现可能优于 ReentrantLock。
考点分析
今天的题目是考察并发编程的常见基础题,我给出的典型回答算是一个相对全面的总结。
对于并发编程,不同公司或者面试官面试风格也不一样,有个别大厂喜欢一直追问你相关机制的扩展或者底层,有的喜欢从实用角度出发,所以你在准备并发编程方面需要一定的耐心。
我认为,锁作为并发的基础工具之一,你至少需要掌握:
理解什么是线程安全。
synchronized、ReentrantLock 等机制的基本使用与案例。
更进一步,你还需要:
掌握 synchronized、ReentrantLock 底层实现;理解锁膨胀、降级;理解偏斜锁、自旋锁、轻量级锁、重量级锁等概念。
掌握并发包中 java.util.concurrent.lock 各种不同实现和案例分析。
知识扩展
专栏前面几期穿插了一些并发的概念,有同学反馈理解起来有点困难,尤其对一些并发相关概念比较陌生,所以在这一讲,我也对会一些基础的概念进行补充。
首先,我们需要理解什么是线程安全。
我建议阅读 Brain Goetz 等专家撰写的《Java 并发编程实战》Java Concurrency in Practice虽然可能稍显学究但不可否认这是一本非常系统和全面的 Java 并发编程书籍。按照其中的定义,线程安全是一个多线程环境下正确性的概念,也就是保证多线程环境下共享的、可修改的状态的正确性,这里的状态反映在程序中其实可以看作是数据。
换个角度来看,如果状态不是共享的,或者不是可修改的,也就不存在线程安全问题,进而可以推理出保证线程安全的两个办法:
封装:通过封装,我们可以将对象内部状态隐藏、保护起来。
不可变:还记得我们在【专栏第 3 讲】强调的 final 和 immutable 吗就是这个道理Java 语言目前还没有真正意义上的原生不可变,但是未来也许会引入。
线程安全需要保证几个基本特性:
原子性,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
可见性是一个线程修改了某个共享变量其状态能够立即被其他线程知晓通常被解释为将线程本地状态反映到主内存上volatile 就是负责保证可见性的。
有序性,是保证线程内串行语义,避免指令重排等。
可能有点晦涩,那么我们看看下面的代码段,分析一下原子性需求体现在哪里。这个例子通过取两次数值然后进行对比,来模拟两次对共享状态的操作。
你可以编译并执行,可以看到,仅仅是两个线程的低度并发,就非常容易碰到 former 和 latter 不相等的情况。这是因为,在两次取值的过程中,其他线程可能已经修改了 sharedState。
public class ThreadSafeSample {
public int sharedState;
public void nonSafeAction() {
while (sharedState < 100000) {
int former = sharedState++;
int latter = sharedState;
if (former != latter - 1) {
System.out.printf("Observed data race, former is " +
former + ", " + "latter is " + latter);
}
}
}
public static void main(String[] args) throws InterruptedException {
ThreadSafeSample sample = new ThreadSafeSample();
Thread threadA = new Thread(){
public void run(){
sample.nonSafeAction();
}
};
Thread threadB = new Thread(){
public void run(){
sample.nonSafeAction();
}
};
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
}
下面是在我的电脑上的运行结果
C:\>c:\jdk-9\bin\java ThreadSafeSample
Observed data race, former is 13097, latter is 13099
将两次赋值过程用 synchronized 保护起来使用 this 作为互斥单元就可以避免别的线程并发的去修改 sharedState
synchronized (this) {
int former = sharedState ++;
int latter = sharedState;
//
}
如果用 javap 反编译可以看到类似片段利用 monitorenter/monitorexit 对实现了同步的语义
11: astore_1
12: monitorenter
13: aload_0
14: dup
15: getfield #2 // Field sharedState:I
18: dup_x1
56: monitorexit
我会在下一讲 synchronized 和其他锁实现的更多底层细节进行深入分析
代码中使用 synchronized 非常便利如果用来修饰静态方法其等同于利用下面代码将方法体囊括进来
synchronized (ClassName.class) {}
再来看看 ReentrantLock你可能好奇什么是再入它是表示当一个线程试图获取一个它已经获取的锁时这个获取动作就自动成功这是对锁获取粒度的一个概念也就是锁的持有是以线程为单位而不是基于调用次数Java 锁实现强调再入性是为了和 pthread 的行为进行区分
再入锁可以设置公平性fairness我们可在创建再入锁时选择是否是公平的
ReentrantLock fairLock = new ReentrantLock(true);
这里所谓的公平性是指在竞争场景中当公平性为真时会倾向于将锁赋予等待时间最久的线程公平性是减少线程饥饿”(个别线程长期等待锁但始终无法获取情况发生的一个办法
如果使用 synchronized我们根本无法进行公平性的选择其永远是不公平的这也是主流操作系统线程调度的选择通用场景中公平性未必有想象中的那么重要Java 默认的调度策略很少会导致 饥饿发生与此同时若要保证公平性则会引入额外开销自然会导致一定的吞吐量下降所以我建议只有当你的程序确实有公平性需要的时候才有必要指定它
我们再从日常编码的角度学习下再入锁为保证锁释放每一个 lock() 动作我建议都立即对应一个 try-catch-finally典型的代码结构如下这是个良好的习惯
ReentrantLock fairLock = new ReentrantLock(true);// 这里是演示创建公平锁一般情况不需要
fairLock.lock();
try {
// do something
} finally {
fairLock.unlock();
}
ReentrantLock 相比 synchronized因为可以像普通对象一样使用所以可以利用其提供的各种便利方法进行精细的同步操作甚至是实现 synchronized 难以表达的用例
带超时的获取锁尝试
可以判断是否有线程或者某个特定线程在排队等待获取锁
可以响应中断请求
这里我特别想强调条件变量java.util.concurrent.Condition如果说 ReentrantLock synchronized 的替代选择Condition 则是将 waitnotifynotifyAll 等操作转化为相应的对象将复杂而晦涩的同步操作转变为直观可控的对象行为
条件变量最为典型的应用场景就是标准类库中的 ArrayBlockingQueue
我们参考下面的源码首先通过再入锁获取条件变量
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
两个条件变量是从同一再入锁创建出来然后使用在特定操作中如下面的 take 方法判断和等待条件满足
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}
当队列为空时试图 take 的线程的正确行为应该是等待入队发生而不是直接返回这是 BlockingQueue 的语义使用条件 notEmpty 就可以优雅地实现这一逻辑
那么怎么保证入队触发后续 take 操作呢请看 enqueue 实现
private void enqueue(E e) {
// assert lock.isHeldByCurrentThread();
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
final Object[] items = this.items;
items[putIndex] = e;
if (++putIndex == items.length) putIndex = 0;
count++;
notEmpty.signal(); // 通知等待的线程非空条件已经满足
}
通过 signal/await 的组合完成了条件判断和通知等待线程非常顺畅就完成了状态流转注意signal await 成对调用非常重要不然假设只有 await 动作线程会一直等待直到被打断interrupt)。
从性能角度synchronized 早期的实现比较低效对比 ReentrantLock大多数场景性能都相差较大但是在 Java 6 中对其进行了非常多的改进可以参考性能对比在高竞争情况下ReentrantLock 仍然有一定优势我在下一讲进行详细分析会更有助于理解性能差异产生的内在原因在大多数情况下无需纠结于性能还是考虑代码书写结构的便利性可维护性等
今天作为专栏进入并发阶段的第一讲我介绍了什么是线程安全对比和分析了 synchronized ReentrantLock并针对条件变量等方面结合案例代码进行了介绍下一讲我将对锁的进阶内容进行源码和案例分析
一课一练
关于今天我们讨论的 synchronized ReentrantLock 你做到心中有数了吗思考一下你使用过 ReentrantLock 中的哪些方法呢分别解决什么问题
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习鼓励金欢迎你与我一起讨论
你的朋友是不是也在准备面试呢你可以请朋友读”,把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,243 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 synchronized和ReentrantLock有什么区别呢
从今天开始我们将进入Java并发学习阶段。软件并发已经成为现代软件开发的基础能力而Java精心设计的高效并发机制正是构建大规模应用的基础之一所以考察并发基本功也成为各个公司面试Java工程师的必选项。
今天我要问你的问题是, synchronized和ReentrantLock有什么区别有人说synchronized最慢这话靠谱吗
典型回答
synchronized是Java内建的同步机制所以也有人称其为Intrinsic Locking它提供了互斥的语义和可见性当一个线程已经获取当前锁时其他试图获取的线程只能等待或者阻塞在那里。
在Java 5以前synchronized是仅有的同步手段在代码中 synchronized可以用来修饰方法也可以使用在特定的代码块儿上本质上synchronized方法等同于把方法全部语句用synchronized块包起来。
ReentrantLock通常翻译为再入锁是Java 5提供的锁实现它的语义和synchronized基本相同。再入锁通过代码直接调用lock()方法获取代码书写也更加灵活。与此同时ReentrantLock提供了很多实用的方法能够实现很多synchronized无法做到的细节控制比如可以控制fairness也就是公平性或者利用定义条件等。但是编码中也需要注意必须要明确调用unlock()方法释放,不然就会一直持有该锁。
synchronized和ReentrantLock的性能不能一概而论早期版本synchronized在很多场景下性能相差较大在后续版本进行了较多改进在低竞争场景中表现可能优于ReentrantLock。
考点分析
今天的题目是考察并发编程的常见基础题,我给出的典型回答算是一个相对全面的总结。
对于并发编程,不同公司或者面试官面试风格也不一样,有个别大厂喜欢一直追问你相关机制的扩展或者底层,有的喜欢从实用角度出发,所以你在准备并发编程方面需要一定的耐心。
我认为,锁作为并发的基础工具之一,你至少需要掌握:
理解什么是线程安全。
synchronized、ReentrantLock等机制的基本使用与案例。
更进一步,你还需要:
掌握synchronized、ReentrantLock底层实现理解锁膨胀、降级理解偏斜锁、自旋锁、轻量级锁、重量级锁等概念。
掌握并发包中java.util.concurrent.lock各种不同实现和案例分析。
知识扩展
专栏前面几期穿插了一些并发的概念,有同学反馈理解起来有点困难,尤其对一些并发相关概念比较陌生,所以在这一讲,我也对会一些基础的概念进行补充。
首先,我们需要理解什么是线程安全。
我建议阅读Brain Goetz等专家撰写的《Java并发编程实战》Java Concurrency in Practice虽然可能稍显学究但不可否认这是一本非常系统和全面的Java并发编程书籍。按照其中的定义线程安全是一个多线程环境下正确性的概念也就是保证多线程环境下共享的、可修改的状态的正确性这里的状态反映在程序中其实可以看作是数据。
换个角度来看,如果状态不是共享的,或者不是可修改的,也就不存在线程安全问题,进而可以推理出保证线程安全的两个办法:
封装:通过封装,我们可以将对象内部状态隐藏、保护起来。
不可变:还记得我们在[专栏第3讲]强调的final和immutable吗就是这个道理Java语言目前还没有真正意义上的原生不可变但是未来也许会引入。
线程安全需要保证几个基本特性:
原子性,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
可见性是一个线程修改了某个共享变量其状态能够立即被其他线程知晓通常被解释为将线程本地状态反映到主内存上volatile就是负责保证可见性的。
有序性,是保证线程内串行语义,避免指令重排等。
可能有点晦涩,那么我们看看下面的代码段,分析一下原子性需求体现在哪里。这个例子通过取两次数值然后进行对比,来模拟两次对共享状态的操作。
你可以编译并执行可以看到仅仅是两个线程的低度并发就非常容易碰到former和latter不相等的情况。这是因为在两次取值的过程中其他线程可能已经修改了sharedState。
public class ThreadSafeSample {
public int sharedState;
public void nonSafeAction() {
while (sharedState < 100000) {
int former = sharedState++;
int latter = sharedState;
if (former != latter - 1) {
System.out.printf("Observed data race, former is " +
former + ", " + "latter is " + latter);
}
}
}
public static void main(String[] args) throws InterruptedException {
ThreadSafeSample sample = new ThreadSafeSample();
Thread threadA = new Thread(){
public void run(){
sample.nonSafeAction();
}
};
Thread threadB = new Thread(){
public void run(){
sample.nonSafeAction();
}
};
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
}
下面是在我的电脑上的运行结果
C:\>c:\jdk-9\bin\java ThreadSafeSample
Observed data race, former is 13097, latter is 13099
将两次赋值过程用synchronized保护起来使用this作为互斥单元就可以避免别的线程并发的去修改sharedState
synchronized (this) {
int former = sharedState ++;
int latter = sharedState;
//
}
如果用javap反编译可以看到类似片段利用monitorenter/monitorexit对实现了同步的语义
11: astore_1
12: monitorenter
13: aload_0
14: dup
15: getfield #2 // Field sharedState:I
18: dup_x1
56: monitorexit
我会在下一讲对synchronized和其他锁实现的更多底层细节进行深入分析
代码中使用synchronized非常便利如果用来修饰静态方法其等同于利用下面代码将方法体囊括进来
synchronized (ClassName.class) {}
再来看看ReentrantLock你可能好奇什么是再入它是表示当一个线程试图获取一个它已经获取的锁时这个获取动作就自动成功这是对锁获取粒度的一个概念也就是锁的持有是以线程为单位而不是基于调用次数Java锁实现强调再入性是为了和pthread的行为进行区分
再入锁可以设置公平性fairness我们可在创建再入锁时选择是否是公平的
ReentrantLock fairLock = new ReentrantLock(true);
这里所谓的公平性是指在竞争场景中当公平性为真时会倾向于将锁赋予等待时间最久的线程公平性是减少线程饥饿”(个别线程长期等待锁但始终无法获取情况发生的一个办法
如果使用synchronized我们根本无法进行公平性的选择其永远是不公平的这也是主流操作系统线程调度的选择通用场景中公平性未必有想象中的那么重要Java默认的调度策略很少会导致 饥饿发生与此同时若要保证公平性则会引入额外开销自然会导致一定的吞吐量下降所以我建议只有当你的程序确实有公平性需要的时候才有必要指定它
我们再从日常编码的角度学习下再入锁为保证锁释放每一个lock()动作我建议都立即对应一个try-catch-finally典型的代码结构如下这是个良好的习惯
ReentrantLock fairLock = new ReentrantLock(true);// 这里是演示创建公平锁一般情况不需要
fairLock.lock();
try {
// do something
} finally {
fairLock.unlock();
}
ReentrantLock相比synchronized因为可以像普通对象一样使用所以可以利用其提供的各种便利方法进行精细的同步操作甚至是实现synchronized难以表达的用例
带超时的获取锁尝试
可以判断是否有线程或者某个特定线程在排队等待获取锁
可以响应中断请求
这里我特别想强调条件变量java.util.concurrent.Condition如果说ReentrantLock是synchronized的替代选择Condition则是将waitnotifynotifyAll等操作转化为相应的对象将复杂而晦涩的同步操作转变为直观可控的对象行为
条件变量最为典型的应用场景就是标准类库中的ArrayBlockingQueue等
我们参考下面的源码首先通过再入锁获取条件变量
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
两个条件变量是从同一再入锁创建出来然后使用在特定操作中如下面的take方法判断和等待条件满足
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}
当队列为空时试图take的线程的正确行为应该是等待入队发生而不是直接返回这是BlockingQueue的语义使用条件notEmpty就可以优雅地实现这一逻辑
那么怎么保证入队触发后续take操作呢请看enqueue实现
private void enqueue(E e) {
// assert lock.isHeldByCurrentThread();
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
final Object[] items = this.items;
items[putIndex] = e;
if (++putIndex == items.length) putIndex = 0;
count++;
notEmpty.signal(); // 通知等待的线程非空条件已经满足
}
通过signal/await的组合完成了条件判断和通知等待线程非常顺畅就完成了状态流转注意signal和await成对调用非常重要不然假设只有await动作线程会一直等待直到被打断interrupt)。
从性能角度synchronized早期的实现比较低效对比ReentrantLock大多数场景性能都相差较大但是在Java 6中对其进行了非常多的改进可以参考性能对比在高竞争情况下ReentrantLock仍然有一定优势我在下一讲进行详细分析会更有助于理解性能差异产生的内在原因在大多数情况下无需纠结于性能还是考虑代码书写结构的便利性可维护性等
今天作为专栏进入并发阶段的第一讲我介绍了什么是线程安全对比和分析了synchronized和ReentrantLock并针对条件变量等方面结合案例代码进行了介绍下一讲我将对锁的进阶内容进行源码和案例分析
一课一练
关于今天我们讨论的synchronized和ReentrantLock你做到心中有数了吗思考一下你使用过ReentrantLock中的哪些方法呢分别解决什么问题
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习鼓励金欢迎你与我一起讨论
你的朋友是不是也在准备面试呢你可以请朋友读”,把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,263 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 synchronized底层如何实现什么是锁的升级、降级-极客时间
我在【上一讲】对比和分析了 synchronized 和 ReentrantLock算是专栏进入并发编程阶段的热身相信你已经对线程安全以及如何使用基本的同步机制有了基础今天我们将深入了解 synchronize 底层机制,分析其他锁实现和应用场景。
今天我要问你的问题是 synchronized 底层如何实现?什么是锁的升级、降级?
典型回答
在回答这个问题前先简单复习一下上一讲的知识点。synchronized 代码块是由一对儿 monitorenter/monitorexit 指令实现的Monitor 对象是同步的基本实现单元。
在 Java 6 之前Monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。
现代的OracleJDK 中JVM 对此进行了大刀阔斧地改进,提供了三种不同的 Monitor 实现也就是常说的三种不同的锁偏斜锁Biased Locking、轻量级锁和重量级锁大大改进了其性能。
所谓锁的升级、降级,就是 JVM 优化 synchronized 运行的机制,当 JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。
当没有竞争出现时默认会使用偏斜锁。JVM 会利用 CAS 操作compare and swap在对象头上的 Mark Word 部分设置线程 ID以表示这个对象偏向于当前线程所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中大部分对象生命周期中最多会被一个线程锁定使用偏斜锁可以降低无竞争开销。
如果有另外的线程试图锁定某个已经被偏斜过的对象JVM 就需要撤销revoke偏斜锁并切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。
我注意到有的观点认为 Java 不会进行锁降级。实际上据我所知,锁降级确实是会发生的,当 JVM 进入安全点SafePoint的时候会检查是否有闲置的 Monitor然后试图进行降级。
考点分析
今天的问题主要是考察你对 Java 内置锁实现的掌握,也是并发的经典题目。我在前面给出的典型回答,涵盖了一些基本概念。如果基础不牢,有些概念理解起来就比较晦涩,我建议还是尽量理解和掌握,即使有不懂的也不用担心,在后续学习中还会逐步加深认识。
我个人认为,能够基础性地理解这些概念和机制,其实对于大多数并发编程已经足够了,毕竟大部分工程师未必会进行更底层、更基础的研发,很多时候解决的是知道与否,真正的提高还要靠实践踩坑。
后面我会进一步分析:
从源码层面,稍微展开一些 synchronized 的底层实现,并补充一些上面答案中欠缺的细节,有同学反馈这部分容易被问到。如果你对 Java 底层源码有兴趣,但还没有找到入手点,这里可以成为一个切入点。
理解并发包中 java.util.concurrent.lock 提供的其他锁实现,毕竟 Java 可不是只有 ReentrantLock 一种显式的锁类型,我会结合代码分析其使用。
知识扩展
我在【上一讲】提到过 synchronized 是 JVM 内部的 Intrinsic Lock所以偏斜锁、轻量级锁、重量级锁的代码实现并不在核心类库部分而是在 JVM 的代码中。
Java 代码运行可能是解释模式也可能是编译模式(如果不记得,请复习【专栏第 1 讲】所以对应的同步逻辑实现也会分散在不同模块下比如解释器版本就是src/hotspot/share/interpreter/interpreterRuntime.cpp
为了简化便于理解我这里会专注于通用的基类实现src/hotspot/share/runtime/
另外请注意,链接指向的是最新 JDK 代码库,所以可能某些实现与历史版本有所不同。
首先synchronized 的行为是 JVM runtime 的一部分,所以我们需要先找到 Runtime 相关的功能实现。通过在代码中查询类似“monitor_enter”或“Monitor Enter”很直观的就可以定位到
sharedRuntime.cpp/hpp它是解释器和编译器运行时的基类。
synchronizer.cpp/hppJVM 同步相关的各种基础逻辑。
在 sharedRuntime.cpp 中,下面代码体现了 synchronized 的主要逻辑。
Handle h_obj(THREAD, obj);
if (UseBiasedLocking) {
// Retry fast entry if bias is revoked to avoid unnecessary inflation
ObjectSynchronizer::fast_enter(h_obj, lock, true, CHECK);
} else {
ObjectSynchronizer::slow_enter(h_obj, lock, CHECK);
}
其实现可以简单进行分解:
UseBiasedLocking 是一个检查,因为,在 JVM 启动时,我们可以指定是否开启偏斜锁。
偏斜锁并不适合所有应用场景撤销操作revoke是比较重的行为只有当存在较多不会真正竞争的 synchronized 块儿时,才能体现出明显改善。实践中对于偏斜锁的一直是有争议的,有人甚至认为,当你需要大量使用并发类库时,往往意味着你不需要偏斜锁。从具体选择来看,我还是建议需要在实践中进行测试,根据结果再决定是否使用。
还有一方面是,偏斜锁会延缓 JIT 预热的进程,所以很多性能测试中会显式地关闭偏斜锁,命令如下:
-XX:-UseBiasedLocking
fast_enter 是我们熟悉的完整锁获取路径slow_enter 则是绕过偏斜锁,直接进入轻量级锁获取逻辑。
那么 fast_enter 是如何实现的呢?同样是通过在代码库搜索,我们可以定位到 synchronizer.cpp。 类似 fast_enter 这种实现,解释器或者动态编译器,都是拷贝这段基础逻辑,所以如果我们修改这部分逻辑,要保证一致性。这部分代码是非常敏感的,微小的问题都可能导致死锁或者正确性问题。
void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock,
bool attempt_rebias, TRAPS) {
if (UseBiasedLocking) {
if (!SafepointSynchronize::is_at_safepoint()) {
BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD);
if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
return;
}
} else {
assert(!attempt_rebias, "can not rebias toward VM thread");
BiasedLocking::revoke_at_safepoint(obj);
}
assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
}
slow_enter(obj, lock, THREAD);
}
我来分析下这段逻辑实现:
biasedLocking定义了偏斜锁相关操作revoke_and_rebias 是获取偏斜锁的入口方法revoke_at_safepoint 则定义了当检测到安全点时的处理逻辑。
如果获取偏斜锁失败,则进入 slow_enter。
这个方法里面同样检查是否开启了偏斜锁,但是从代码路径来看,其实如果关闭了偏斜锁,是不会进入这个方法的,所以算是个额外的保障性检查吧。
另外如果你仔细查看synchronizer.cpp里会发现不仅仅是 synchronized 的逻辑,包括从本地代码,也就是 JNI触发的 Monitor 动作全都可以在里面找到jni_enter/jni_exit
关于biasedLocking的更多细节我就不展开了明白它是通过 CAS 设置 Mark Word 就完全够用了,对象头中 Mark Word 的结构,可以参考下图:
顺着锁升降级的过程分析下去,偏斜锁到轻量级锁的过程是如何实现的呢?
我们来看看 slow_enter 到底做了什么。
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
markOop mark = obj->mark();
if (mark->is_neutral()) {
// 将目前的Mark Word复制到Displaced Header上
lock->set_displaced_header(mark);
// 利用CAS设置对象的Mark Word
if (mark == obj()->cas_set_mark((markOop) lock, mark)) {
TEVENT(slow_enter: release stacklock);
return;
}
// 检查存在竞争
} else if (mark->has_locker() &&
THREAD->is_lock_owned((address)mark->locker())) {
// 清除
lock->set_displaced_header(NULL);
return;
}
// 重置Displaced Header
lock->set_displaced_header(markOopDesc::unused_mark());
ObjectSynchronizer::inflate(THREAD,
obj(),
inflate_cause_monitor_enter)->enter(THREAD);
}
请结合我在代码中添加的注释,来理解如何从试图获取轻量级锁,逐步进入锁膨胀的过程。你可以发现这个处理逻辑,和我在这一讲最初介绍的过程是十分吻合的。
设置 Displaced Header然后利用 cas_set_mark 设置对象 Mark Word如果成功就成功获取轻量级锁。
否则 Displaced Header然后进入锁膨胀阶段具体实现在 inflate 方法中。
今天就不介绍膨胀的细节了我这里提供了源代码分析的思路和样例考虑到应用实践再进一步增加源代码解读意义不大有兴趣的同学可以参考我提供的synchronizer.cpp链接例如
deflate_idle_monitors 是分析锁降级逻辑的入口,这部分行为还在进行持续改进,因为其逻辑是在安全点内运行,处理不当可能拖长 JVM 停顿STWstop-the-world的时间。
fast_exit 或者 slow_exit 是对应的锁释放逻辑。
前面分析了 synchronized 的底层实现,理解起来有一定难度,下面我们来看一些相对轻松的内容。 我在上一讲对比了 synchronized 和 ReentrantLockJava 核心类库中还有其他一些特别的锁类型,具体请参考下面的图。
你可能注意到了,这些锁竟然不都是实现了 Lock 接口ReadWriteLock 是一个单独的接口它通常是代表了一对儿锁分别对应只读和写操作标准类库中提供了再入版本的读写锁实现ReentrantReadWriteLock对应的语义和 ReentrantLock 比较相似。
StampedLock 竟然也是个单独的类型,从类图结构可以看出它是不支持再入性的语义的,也就是它不是以持有锁的线程为单位。
为什么我们需要读写锁ReadWriteLock等其他锁呢
这是因为,虽然 ReentrantLock 和 synchronized 简单实用,但是行为上有一定局限性,通俗点说就是“太霸道”,要么不占,要么独占。实际应用场景中,有的时候不需要大量竞争的写操作,而是以并发读取为主,如何进一步优化并发操作的粒度呢?
Java 并发包提供的读写锁等扩展了锁的能力,它所基于的原理是多个读操作是不需要互斥的,因为读操作并不会更改数据,所以不存在互相干扰。而写操作则会导致并发一致性的问题,所以写线程之间、读写线程之间,需要精心设计的互斥逻辑。
下面是一个基于读写锁实现的数据结构,当数据量较大,并发读多、并发写少的时候,能够比纯同步版本凸显出优势。
public class RWSample {
private final Map<String, String> m = new TreeMap<>();
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
public String get(String key) {
r.lock();
System.out.println("读锁锁定!");
try {
return m.get(key);
} finally {
r.unlock();
}
}
public String put(String key, String entry) {
w.lock();
System.out.println("写锁锁定!");
try {
return m.put(key, entry);
} finally {
w.unlock();
}
}
// …
}
在运行过程中,如果读锁试图锁定时,写锁是被某个线程持有,读锁将无法获得,而只好等待对方操作结束,这样就可以自动保证不会读取到有争议的数据。
读写锁看起来比 synchronized 的粒度似乎细一些,但在实际应用中,其表现也并不尽如人意,主要还是因为相对比较大的开销。
所以JDK 在后期引入了 StampedLock在提供类似读写锁的同时还支持优化读模式。优化读基于假设大多数情况下读操作并不会和写操作冲突其逻辑是先试着读然后通过 validate 方法确认是否进入了写模式,如果没有进入,就成功避免了开销;如果进入,则尝试获取读锁。请参考我下面的样例代码。
public class StampedSample {
private final StampedLock sl = new StampedLock();
void mutate() {
long stamp = sl.writeLock();
try {
write();
} finally {
sl.unlockWrite(stamp);
}
}
Data access() {
long stamp = sl.tryOptimisticRead();
Data data = read();
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try {
data = read();
} finally {
sl.unlockRead(stamp);
}
}
return data;
}
// …
}
注意,这里的 writeLock 和 unLockWrite 一定要保证成对调用。
你可能很好奇这些显式锁的实现机制Java 并发包内的各种同步工具,不仅仅是各种 Lock其他的如Semaphore、CountDownLatch甚至是早期的FutureTask等都是基于一种AQS框架。
今天,我全面分析了 synchronized 相关实现和内部运行机制,简单介绍了并发包中提供的其他显式锁,并结合样例代码介绍了其使用方法,希望对你有所帮助。
一课一练
关于今天我们讨论的你做到心中有数了吗?思考一个问题,你知道“自旋锁”是做什么的吗?它的使用场景是什么?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,269 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 synchronized底层如何实现什么是锁的升级、降级
我在[上一讲]对比和分析了synchronized和ReentrantLock算是专栏进入并发编程阶段的热身相信你已经对线程安全以及如何使用基本的同步机制有了基础今天我们将深入了解synchronize底层机制分析其他锁实现和应用场景。
今天我要问你的问题是 synchronized底层如何实现什么是锁的升级、降级
典型回答
在回答这个问题前先简单复习一下上一讲的知识点。synchronized代码块是由一对儿monitorenter/monitorexit指令实现的Monitor对象是同步的基本实现单元。
在Java 6之前Monitor的实现完全是依靠操作系统内部的互斥锁因为需要进行用户态到内核态的切换所以同步操作是一个无差别的重量级操作。
现代的OracleJDK中JVM对此进行了大刀阔斧地改进提供了三种不同的Monitor实现也就是常说的三种不同的锁偏斜锁Biased Locking、轻量级锁和重量级锁大大改进了其性能。
所谓锁的升级、降级就是JVM优化synchronized运行的机制当JVM检测到不同的竞争状况时会自动切换到适合的锁实现这种切换就是锁的升级、降级。
当没有竞争出现时默认会使用偏斜锁。JVM会利用CAS操作compare and swap在对象头上的Mark Word部分设置线程ID以表示这个对象偏向于当前线程所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中大部分对象生命周期中最多会被一个线程锁定使用偏斜锁可以降低无竞争开销。
如果有另外的线程试图锁定某个已经被偏斜过的对象JVM就需要撤销revoke偏斜锁并切换到轻量级锁实现。轻量级锁依赖CAS操作Mark Word来试图获取锁如果重试成功就使用普通的轻量级锁否则进一步升级为重量级锁。
我注意到有的观点认为Java不会进行锁降级。实际上据我所知锁降级确实是会发生的当JVM进入安全点SafePoint的时候会检查是否有闲置的Monitor然后试图进行降级。
考点分析
今天的问题主要是考察你对Java内置锁实现的掌握也是并发的经典题目。我在前面给出的典型回答涵盖了一些基本概念。如果基础不牢有些概念理解起来就比较晦涩我建议还是尽量理解和掌握即使有不懂的也不用担心在后续学习中还会逐步加深认识。
我个人认为,能够基础性地理解这些概念和机制,其实对于大多数并发编程已经足够了,毕竟大部分工程师未必会进行更底层、更基础的研发,很多时候解决的是知道与否,真正的提高还要靠实践踩坑。
后面我会进一步分析:
从源码层面稍微展开一些synchronized的底层实现并补充一些上面答案中欠缺的细节有同学反馈这部分容易被问到。如果你对Java底层源码有兴趣但还没有找到入手点这里可以成为一个切入点。
理解并发包中java.util.concurrent.lock提供的其他锁实现毕竟Java可不是只有ReentrantLock一种显式的锁类型我会结合代码分析其使用。
知识扩展
我在[上一讲]提到过synchronized是JVM内部的Intrinsic Lock所以偏斜锁、轻量级锁、重量级锁的代码实现并不在核心类库部分而是在JVM的代码中。
Java代码运行可能是解释模式也可能是编译模式如果不记得请复习[专栏第1讲]),所以对应的同步逻辑实现,也会分散在不同模块下,比如,解释器版本就是:
src/hotspot/share/interpreter/interpreterRuntime.cpp
为了简化便于理解,我这里会专注于通用的基类实现:
src/hotspot/share/runtime/
另外请注意链接指向的是最新JDK代码库所以可能某些实现与历史版本有所不同。
首先synchronized的行为是JVM runtime的一部分所以我们需要先找到Runtime相关的功能实现。通过在代码中查询类似“monitor_enter”或“Monitor Enter”很直观的就可以定位到
sharedRuntime.cpp/hpp它是解释器和编译器运行时的基类。
synchronizer.cpp/hppJVM同步相关的各种基础逻辑。
在sharedRuntime.cpp中下面代码体现了synchronized的主要逻辑。
Handle h_obj(THREAD, obj);
if (UseBiasedLocking) {
// Retry fast entry if bias is revoked to avoid unnecessary inflation
ObjectSynchronizer::fast_enter(h_obj, lock, true, CHECK);
} else {
ObjectSynchronizer::slow_enter(h_obj, lock, CHECK);
}
其实现可以简单进行分解:
UseBiasedLocking是一个检查因为在JVM启动时我们可以指定是否开启偏斜锁。
偏斜锁并不适合所有应用场景撤销操作revoke是比较重的行为只有当存在较多不会真正竞争的synchronized块儿时才能体现出明显改善。实践中对于偏斜锁的一直是有争议的有人甚至认为当你需要大量使用并发类库时往往意味着你不需要偏斜锁。从具体选择来看我还是建议需要在实践中进行测试根据结果再决定是否使用。
还有一方面是偏斜锁会延缓JIT 预热的进程,所以很多性能测试中会显式地关闭偏斜锁,命令如下:
-XX:-UseBiasedLocking
fast_enter是我们熟悉的完整锁获取路径slow_enter则是绕过偏斜锁直接进入轻量级锁获取逻辑。
那么fast_enter是如何实现的呢同样是通过在代码库搜索我们可以定位到synchronizer.cpp。 类似fast_enter这种实现解释器或者动态编译器都是拷贝这段基础逻辑所以如果我们修改这部分逻辑要保证一致性。这部分代码是非常敏感的微小的问题都可能导致死锁或者正确性问题。
void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock,
bool attempt_rebias, TRAPS) {
if (UseBiasedLocking) {
if (!SafepointSynchronize::is_at_safepoint()) {
BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD);
if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
return;
}
} else {
assert(!attempt_rebias, "can not rebias toward VM thread");
BiasedLocking::revoke_at_safepoint(obj);
}
assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
}
slow_enter(obj, lock, THREAD);
}
我来分析下这段逻辑实现:
biasedLocking定义了偏斜锁相关操作revoke_and_rebias是获取偏斜锁的入口方法revoke_at_safepoint则定义了当检测到安全点时的处理逻辑。
如果获取偏斜锁失败则进入slow_enter。
这个方法里面同样检查是否开启了偏斜锁,但是从代码路径来看,其实如果关闭了偏斜锁,是不会进入这个方法的,所以算是个额外的保障性检查吧。
另外如果你仔细查看synchronizer.cpp里会发现不仅仅是synchronized的逻辑包括从本地代码也就是JNI触发的Monitor动作全都可以在里面找到jni_enter/jni_exit
关于biasedLocking的更多细节我就不展开了明白它是通过CAS设置Mark Word就完全够用了对象头中Mark Word的结构可以参考下图
顺着锁升降级的过程分析下去,偏斜锁到轻量级锁的过程是如何实现的呢?
我们来看看slow_enter到底做了什么。
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
markOop mark = obj->mark();
if (mark->is_neutral()) {
// 将目前的Mark Word复制到Displaced Header上
lock->set_displaced_header(mark);
// 利用CAS设置对象的Mark Word
if (mark == obj()->cas_set_mark((markOop) lock, mark)) {
TEVENT(slow_enter: release stacklock);
return;
}
// 检查存在竞争
} else if (mark->has_locker() &&
THREAD->is_lock_owned((address)mark->locker())) {
// 清除
lock->set_displaced_header(NULL);
return;
}
// 重置Displaced Header
lock->set_displaced_header(markOopDesc::unused_mark());
ObjectSynchronizer::inflate(THREAD,
obj(),
inflate_cause_monitor_enter)->enter(THREAD);
}
请结合我在代码中添加的注释,来理解如何从试图获取轻量级锁,逐步进入锁膨胀的过程。你可以发现这个处理逻辑,和我在这一讲最初介绍的过程是十分吻合的。
设置Displaced Header然后利用cas_set_mark设置对象Mark Word如果成功就成功获取轻量级锁。
否则Displaced Header然后进入锁膨胀阶段具体实现在inflate方法中。
今天就不介绍膨胀的细节了我这里提供了源代码分析的思路和样例考虑到应用实践再进一步增加源代码解读意义不大有兴趣的同学可以参考我提供的synchronizer.cpp链接例如
deflate_idle_monitors是分析锁降级逻辑的入口这部分行为还在进行持续改进因为其逻辑是在安全点内运行处理不当可能拖长JVM停顿STWstop-the-world的时间。
fast_exit或者slow_exit是对应的锁释放逻辑。
前面分析了synchronized的底层实现理解起来有一定难度下面我们来看一些相对轻松的内容。 我在上一讲对比了synchronized和ReentrantLockJava核心类库中还有其他一些特别的锁类型具体请参考下面的图。
你可能注意到了这些锁竟然不都是实现了Lock接口ReadWriteLock是一个单独的接口它通常是代表了一对儿锁分别对应只读和写操作标准类库中提供了再入版本的读写锁实现ReentrantReadWriteLock对应的语义和ReentrantLock比较相似。
StampedLock竟然也是个单独的类型从类图结构可以看出它是不支持再入性的语义的也就是它不是以持有锁的线程为单位。
为什么我们需要读写锁ReadWriteLock等其他锁呢
这是因为虽然ReentrantLock和synchronized简单实用但是行为上有一定局限性通俗点说就是“太霸道”要么不占要么独占。实际应用场景中有的时候不需要大量竞争的写操作而是以并发读取为主如何进一步优化并发操作的粒度呢
Java并发包提供的读写锁等扩展了锁的能力它所基于的原理是多个读操作是不需要互斥的因为读操作并不会更改数据所以不存在互相干扰。而写操作则会导致并发一致性的问题所以写线程之间、读写线程之间需要精心设计的互斥逻辑。
下面是一个基于读写锁实现的数据结构,当数据量较大,并发读多、并发写少的时候,能够比纯同步版本凸显出优势。
public class RWSample {
private final Map<String, String> m = new TreeMap<>();
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
public String get(String key) {
r.lock();
System.out.println("读锁锁定!");
try {
return m.get(key);
} finally {
r.unlock();
}
}
public String put(String key, String entry) {
w.lock();
System.out.println("写锁锁定!");
try {
return m.put(key, entry);
} finally {
w.unlock();
}
}
// …
}
在运行过程中,如果读锁试图锁定时,写锁是被某个线程持有,读锁将无法获得,而只好等待对方操作结束,这样就可以自动保证不会读取到有争议的数据。
读写锁看起来比synchronized的粒度似乎细一些但在实际应用中其表现也并不尽如人意主要还是因为相对比较大的开销。
所以JDK在后期引入了StampedLock在提供类似读写锁的同时还支持优化读模式。优化读基于假设大多数情况下读操作并不会和写操作冲突其逻辑是先试着读然后通过validate方法确认是否进入了写模式如果没有进入就成功避免了开销如果进入则尝试获取读锁。请参考我下面的样例代码。
public class StampedSample {
private final StampedLock sl = new StampedLock();
void mutate() {
long stamp = sl.writeLock();
try {
write();
} finally {
sl.unlockWrite(stamp);
}
}
Data access() {
long stamp = sl.tryOptimisticRead();
Data data = read();
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try {
data = read();
} finally {
sl.unlockRead(stamp);
}
}
return data;
}
// …
}
注意这里的writeLock和unLockWrite一定要保证成对调用。
你可能很好奇这些显式锁的实现机制Java并发包内的各种同步工具不仅仅是各种Lock其他的如Semaphore、CountDownLatch甚至是早期的FutureTask等都是基于一种AQS框架。
今天我全面分析了synchronized相关实现和内部运行机制简单介绍了并发包中提供的其他显式锁并结合样例代码介绍了其使用方法希望对你有所帮助。
一课一练
关于今天我们讨论的你做到心中有数了吗?思考一个问题,你知道“自旋锁”是做什么的吗?它的使用场景是什么?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,185 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 一个线程两次调用start()方法会出现什么情况?-极客时间
今天我们来深入聊聊线程,相信大家对于线程这个概念都不陌生,它是 Java 并发的基础元素,理解、操纵、诊断线程是 Java 工程师的必修课,但是你真的掌握线程了吗?
今天我要问你的问题是,一个线程两次调用 start() 方法会出现什么情况?谈谈线程的生命周期和状态转移。
典型回答
Java 的线程是不允许启动两次的,第二次调用必然会抛出 IllegalThreadStateException这是一种运行时异常多次调用 start 被认为是编程错误。
关于线程生命周期的不同状态,在 Java 5 以后,线程状态被明确定义在其公共内部枚举类型 java.lang.Thread.State 中,分别是:
新建NEW表示线程被创建出来还没真正启动的状态可以认为它是个 Java 内部状态。
就绪RUNNABLE表示该线程已经在 JVM 中执行,当然由于执行需要计算资源,它可能是正在运行,也可能还在等待系统分配给它 CPU 片段,在就绪队列里面排队。
在其他一些分析中,会额外区分一种状态 RUNNING但是从 Java API 的角度,并不能表示出来。
阻塞BLOCKED这个状态和我们前面两讲介绍的同步非常相关阻塞表示线程在等待 Monitor lock。比如线程试图通过 synchronized 去获取某个锁,但是其他线程已经独占了,那么当前线程就会处于阻塞状态。
等待WAITING表示正在等待其他线程采取某些操作。一个常见的场景是类似生产者消费者模式发现任务条件尚未满足就让当前消费者线程等待wait另外的生产者线程去准备任务数据然后通过类似 notify 等动作通知消费线程可以继续工作了。Thread.join() 也会令线程进入等待状态。
计时等待TIMED_WAIT其进入条件和等待状态类似但是调用的是存在超时条件的方法比如 wait 或 join 等方法的指定超时版本,如下面示例:
public final native void wait(long timeout) throws InterruptedException;
终止TERMINATED不管是意外退出还是正常执行结束线程已经完成使命终止运行也有人把这个状态叫作死亡。
在第二次调用 start() 方法的时候,线程可能处于终止或者其他(非 NEW状态但是不论如何都是不可以再次启动的。
考点分析
今天的问题可以算是个常见的面试热身题目,前面的给出的典型回答,算是对基本状态和简单流转的一个介绍,如果觉得还不够直观,我在下面分析会对比一个状态图进行介绍。总的来说,理解线程对于我们日常开发或者诊断分析,都是不可或缺的基础。
面试官可能会以此为契机,从各种不同角度考察你对线程的掌握:
相对理论一些的面试官可以会问你线程到底是什么以及 Java 底层实现方式。
线程状态的切换,以及和锁等并发工具类的互动。
线程编程时容易踩的坑与建议等。
可以看出,仅仅是一个线程,就有非常多的内容需要掌握。我们选择重点内容,开始进入详细分析。
知识扩展
首先,我们来整体看一下线程是什么?
从操作系统的角度可以简单认为线程是系统调度的最小单元一个进程可以包含多个线程作为任务的真正运作者有自己的栈Stack、寄存器Register、本地存储Thread Local但是会和进程内其他线程共享文件描述符、虚拟地址空间等。
在具体实现中线程还分为内核线程、用户线程Java 的线程实现其实是与虚拟机相关的。对于我们最熟悉的 Sun/Oracle JDK其线程也经历了一个演进过程基本上在 Java 1.2 之后JDK 已经抛弃了所谓的Green Thread也就是用户调度的线程现在的模型是一对一映射到操作系统内核线程。
如果我们来看 Thread 的源码,你会发现其基本操作逻辑大都是以 JNI 形式调用的本地代码。
private native void start0();
private native void setPriority0(int newPriority);
private native void interrupt0();
这种实现有利有弊总体上来说Java 语言得益于精细粒度的线程和相关的并发操作,其构建高扩展性的大型应用的能力已经毋庸置疑。但是,其复杂性也提高了并发编程的门槛,近几年的 Go 语言等提供了协程coroutine大大提高了构建并发应用的效率。于此同时Java 也在Loom项目中孕育新的类似轻量级用户线程Fiber等机制也许在不久的将来就可以在新版 JDK 中使用到它。
下面,我来分析下线程的基本操作。如何创建线程想必你已经非常熟悉了,请看下面的例子:
Runnable task = () -> {System.out.println("Hello World!");};
Thread myThread = new Thread(task);
myThread.start();
myThread.join();
我们可以直接扩展 Thread 类,然后实例化。但在本例中,我选取了另外一种方式,就是实现一个 Runnable将代码逻放在 Runnable 中,然后构建 Thread 并启动start等待结束join
Runnable 的好处是,不会受 Java 不支持类多继承的限制,重用代码实现,当我们需要重复执行相应逻辑时优点明显。而且,也能更好的与现代 Java 并发库中的 Executor 之类框架结合使用,比如将上面 start 和 join 的逻辑完全写成下面的结构:
Future future = Executors.newFixedThreadPool(1)
.submit(task)
.get();
这样我们就不用操心线程的创建和管理,也能利用 Future 等机制更好地处理执行结果。线程生命周期通常和业务之间没有本质联系,混淆实现需求和业务需求,就会降低开发的效率。
从线程生命周期的状态开始展开,那么在 Java 编程中,有哪些因素可能影响线程的状态呢?主要有:
线程自身的方法,除了 start还有多个 join 方法等待线程结束yield 是告诉调度器,主动让出 CPU另外就是一些已经被标记为过时的 resume、stop、suspend 之类,据我所知,在 JDK 最新版本中destory/stop 方法将被直接移除。
基类 Object 提供了一些基础的 wait/notify/notifyAll 方法。如果我们持有某个对象的 Monitor 锁,调用 wait 会让当前线程处于等待状态,直到其他线程 notify 或者 notifyAll。所以本质上是提供了 Monitor 的获取和释放的能力,是基本的线程间通信方式。
并发类库中的工具,比如 CountDownLatch.await() 会让当前线程进入等待状态,直到 latch 被基数为 0这可以看作是线程间通信的 Signal。
我这里画了一个状态和方法之间的对应图:
Thread 和 Object 的方法,听起来简单,但是实际应用中被证明非常晦涩、易错,这也是为什么 Java 后来又引入了并发包。总的来说,有了并发包,大多数情况下,我们已经不再需要去调用 wait/notify 之类的方法了。
前面谈了不少理论,下面谈谈线程 API 使用,我会侧重于平时工作学习中,容易被忽略的一些方面。
先来看看守护线程Daemon Thread有的时候应用中需要一个长期驻留的服务程序但是不希望其影响应用退出就可以将其设置为守护线程如果 JVM 发现只有守护线程存在时,将结束进程,具体可以参考下面代码段。注意,必须在线程启动之前设置。
Thread daemonThread = new Thread();
daemonThread.setDaemon(true);
daemonThread.start();
再来看看Spurious wakeup。尤其是在多核 CPU 的系统中,线程等待存在一种可能,就是在没有任何线程广播或者发出信号的情况下,线程就被唤醒,如果处理不当就可能出现诡异的并发问题,所以我们在等待条件过程中,建议采用下面模式来书写。
// 推荐
while ( isCondition()) {
waitForAConfition(...);
}
// 不推荐可能引入bug
if ( isCondition()) {
waitForAConfition(...);
}
Thread.onSpinWait(),这是 Java 9 中引入的特性。我在【专栏第 16 讲】给你留的思考题中提到“自旋锁”spin-wait, busy-waiting也可以认为其不算是一种锁而是一种针对短期等待的性能优化技术。“onSpinWait()”没有任何行为上的保证,而是对 JVM 的一个暗示JVM 可能会利用 CPU 的 pause 指令进一步提高性能,性能特别敏感的应用可以关注。
再有就是慎用ThreadLocal这是 Java 提供的一种保存线程私有信息的机制,因为其在整个线程生命周期内有效,所以可以方便地在一个线程关联的不同业务模块之间传递信息,比如事务 ID、Cookie 等上下文相关信息。
它的实现结构,可以参考源码,数据存储于线程相关的 ThreadLocalMap其内部条目是弱引用如下面片段。
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// …
}
当 Key 为 null 时该条目就变成“废弃条目”相关“value”的回收往往依赖于几个关键点即 set、remove、rehash。
下面是 set 的示例,我进行了精简和注释:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];; …) {
//…
if (k == null) {
// 替换废弃条目
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
// 扫描并清理发现的废弃条目,并检查容量是否超限
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();// 清理废弃条目,如果仍然超限,则扩容(加倍)
}
具体的清理逻辑是实现在 cleanSomeSlots 和 expungeStaleEntry 之中,如果你有兴趣可以自行阅读。
结合【专栏第 4 讲】介绍的引用类型,我们会发现一个特别的地方,通常弱引用都会和引用队列配合清理机制使用,但是 ThreadLocal 是个例外,它并没有这么做。
这意味着,废弃项目的回收依赖于显式地触发,否则就要等待线程结束,进而回收相应 ThreadLocalMap这就是很多 OOM 的来源,所以通常都会建议,应用一定要自己负责 remove并且不要和线程池配合因为 worker 线程往往是不会退出的。
今天,我介绍了线程基础,分析了生命周期中的状态和各种方法之间的对应关系,这也有助于我们更好地理解 synchronized 和锁的影响,并介绍了一些需要注意的操作,希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天我准备了一个有意思的问题,写一个最简单的打印 HelloWorld 的程序说说看运行这个应用Java 至少会创建几个线程呢?然后思考一下,如何明确验证你的结论,真实情况很可能令你大跌眼镜哦。
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,193 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 一个线程两次调用start()方法会出现什么情况?
今天我们来深入聊聊线程相信大家对于线程这个概念都不陌生它是Java并发的基础元素理解、操纵、诊断线程是Java工程师的必修课但是你真的掌握线程了吗
今天我要问你的问题是一个线程两次调用start()方法会出现什么情况?谈谈线程的生命周期和状态转移。
典型回答
Java的线程是不允许启动两次的第二次调用必然会抛出IllegalThreadStateException这是一种运行时异常多次调用start被认为是编程错误。
关于线程生命周期的不同状态在Java 5以后线程状态被明确定义在其公共内部枚举类型java.lang.Thread.State中分别是
新建NEW表示线程被创建出来还没真正启动的状态可以认为它是个Java内部状态。
就绪RUNNABLE表示该线程已经在JVM中执行当然由于执行需要计算资源它可能是正在运行也可能还在等待系统分配给它CPU片段在就绪队列里面排队。
在其他一些分析中会额外区分一种状态RUNNING但是从Java API的角度并不能表示出来。
阻塞BLOCKED这个状态和我们前面两讲介绍的同步非常相关阻塞表示线程在等待Monitor lock。比如线程试图通过synchronized去获取某个锁但是其他线程已经独占了那么当前线程就会处于阻塞状态。
等待WAITING表示正在等待其他线程采取某些操作。一个常见的场景是类似生产者消费者模式发现任务条件尚未满足就让当前消费者线程等待wait另外的生产者线程去准备任务数据然后通过类似notify等动作通知消费线程可以继续工作了。Thread.join()也会令线程进入等待状态。
计时等待TIMED_WAIT其进入条件和等待状态类似但是调用的是存在超时条件的方法比如wait或join等方法的指定超时版本如下面示例
public final native void wait(long timeout) throws InterruptedException;
终止TERMINATED不管是意外退出还是正常执行结束线程已经完成使命终止运行也有人把这个状态叫作死亡。
在第二次调用start()方法的时候线程可能处于终止或者其他非NEW状态但是不论如何都是不可以再次启动的。
考点分析
今天的问题可以算是个常见的面试热身题目,前面的给出的典型回答,算是对基本状态和简单流转的一个介绍,如果觉得还不够直观,我在下面分析会对比一个状态图进行介绍。总的来说,理解线程对于我们日常开发或者诊断分析,都是不可或缺的基础。
面试官可能会以此为契机,从各种不同角度考察你对线程的掌握:
相对理论一些的面试官可以会问你线程到底是什么以及Java底层实现方式。
线程状态的切换,以及和锁等并发工具类的互动。
线程编程时容易踩的坑与建议等。
可以看出,仅仅是一个线程,就有非常多的内容需要掌握。我们选择重点内容,开始进入详细分析。
知识扩展
首先,我们来整体看一下线程是什么?
从操作系统的角度可以简单认为线程是系统调度的最小单元一个进程可以包含多个线程作为任务的真正运作者有自己的栈Stack、寄存器Register、本地存储Thread Local但是会和进程内其他线程共享文件描述符、虚拟地址空间等。
在具体实现中线程还分为内核线程、用户线程Java的线程实现其实是与虚拟机相关的。对于我们最熟悉的Sun/Oracle JDK其线程也经历了一个演进过程基本上在Java 1.2之后JDK已经抛弃了所谓的Green Thread也就是用户调度的线程现在的模型是一对一映射到操作系统内核线程。
如果我们来看Thread的源码你会发现其基本操作逻辑大都是以JNI形式调用的本地代码。
private native void start0();
private native void setPriority0(int newPriority);
private native void interrupt0();
这种实现有利有弊总体上来说Java语言得益于精细粒度的线程和相关的并发操作其构建高扩展性的大型应用的能力已经毋庸置疑。但是其复杂性也提高了并发编程的门槛近几年的Go语言等提供了协程coroutine大大提高了构建并发应用的效率。于此同时Java也在Loom项目中孕育新的类似轻量级用户线程Fiber等机制也许在不久的将来就可以在新版JDK中使用到它。
下面,我来分析下线程的基本操作。如何创建线程想必你已经非常熟悉了,请看下面的例子:
Runnable task = () -> {System.out.println("Hello World!");};
Thread myThread = new Thread(task);
myThread.start();
myThread.join();
我们可以直接扩展Thread类然后实例化。但在本例中我选取了另外一种方式就是实现一个Runnable将代码逻放在Runnable中然后构建Thread并启动start等待结束join
Runnable的好处是不会受Java不支持类多继承的限制重用代码实现当我们需要重复执行相应逻辑时优点明显。而且也能更好的与现代Java并发库中的Executor之类框架结合使用比如将上面start和join的逻辑完全写成下面的结构
Future future = Executors.newFixedThreadPool(1)
.submit(task)
.get();
这样我们就不用操心线程的创建和管理也能利用Future等机制更好地处理执行结果。线程生命周期通常和业务之间没有本质联系混淆实现需求和业务需求就会降低开发的效率。
从线程生命周期的状态开始展开那么在Java编程中有哪些因素可能影响线程的状态呢主要有
线程自身的方法除了start还有多个join方法等待线程结束yield是告诉调度器主动让出CPU另外就是一些已经被标记为过时的resume、stop、suspend之类据我所知在JDK最新版本中destory/stop方法将被直接移除。
基类Object提供了一些基础的wait/notify/notifyAll方法。如果我们持有某个对象的Monitor锁调用wait会让当前线程处于等待状态直到其他线程notify或者notifyAll。所以本质上是提供了Monitor的获取和释放的能力是基本的线程间通信方式。
并发类库中的工具比如CountDownLatch.await()会让当前线程进入等待状态直到latch被基数为0这可以看作是线程间通信的Signal。
我这里画了一个状态和方法之间的对应图:
Thread和Object的方法听起来简单但是实际应用中被证明非常晦涩、易错这也是为什么Java后来又引入了并发包。总的来说有了并发包大多数情况下我们已经不再需要去调用wait/notify之类的方法了。
前面谈了不少理论下面谈谈线程API使用我会侧重于平时工作学习中容易被忽略的一些方面。
先来看看守护线程Daemon Thread有的时候应用中需要一个长期驻留的服务程序但是不希望其影响应用退出就可以将其设置为守护线程如果JVM发现只有守护线程存在时将结束进程具体可以参考下面代码段。注意必须在线程启动之前设置。
Thread daemonThread = new Thread();
daemonThread.setDaemon(true);
daemonThread.start();
再来看看Spurious wakeup。尤其是在多核CPU的系统中线程等待存在一种可能就是在没有任何线程广播或者发出信号的情况下线程就被唤醒如果处理不当就可能出现诡异的并发问题所以我们在等待条件过程中建议采用下面模式来书写。
// 推荐
while ( isCondition()) {
waitForAConfition(...);
}
// 不推荐可能引入bug
if ( isCondition()) {
waitForAConfition(...);
}
Thread.onSpinWait()这是Java 9中引入的特性。我在[专栏第16讲]给你留的思考题中提到“自旋锁”spin-wait, busy-waiting也可以认为其不算是一种锁而是一种针对短期等待的性能优化技术。“onSpinWait()”没有任何行为上的保证而是对JVM的一个暗示JVM可能会利用CPU的pause指令进一步提高性能性能特别敏感的应用可以关注。
再有就是慎用ThreadLocal这是Java提供的一种保存线程私有信息的机制因为其在整个线程生命周期内有效所以可以方便地在一个线程关联的不同业务模块之间传递信息比如事务ID、Cookie等上下文相关信息。
它的实现结构可以参考源码数据存储于线程相关的ThreadLocalMap其内部条目是弱引用如下面片段。
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// …
}
当Key为null时该条目就变成“废弃条目”相关“value”的回收往往依赖于几个关键点即set、remove、rehash。
下面是set的示例我进行了精简和注释
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];; …) {
//…
if (k == null) {
// 替换废弃条目
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
// 扫描并清理发现的废弃条目,并检查容量是否超限
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();// 清理废弃条目,如果仍然超限,则扩容(加倍)
}
具体的清理逻辑是实现在cleanSomeSlots和expungeStaleEntry之中如果你有兴趣可以自行阅读。
结合[专栏第4讲]介绍的引用类型我们会发现一个特别的地方通常弱引用都会和引用队列配合清理机制使用但是ThreadLocal是个例外它并没有这么做。
这意味着废弃项目的回收依赖于显式地触发否则就要等待线程结束进而回收相应ThreadLocalMap这就是很多OOM的来源所以通常都会建议应用一定要自己负责remove并且不要和线程池配合因为worker线程往往是不会退出的。
今天我介绍了线程基础分析了生命周期中的状态和各种方法之间的对应关系这也有助于我们更好地理解synchronized和锁的影响并介绍了一些需要注意的操作希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天我准备了一个有意思的问题写一个最简单的打印HelloWorld的程序说说看运行这个应用Java至少会创建几个线程呢然后思考一下如何明确验证你的结论真实情况很可能令你大跌眼镜哦。
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,224 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 什么情况下Java程序会产生死锁如何定位、修复-极客时间
今天,我会介绍一些日常开发中类似线程死锁等问题的排查经验,并选择一两个我自己修复过或者诊断过的核心类库死锁问题作为例子,希望不仅能在面试时,包括在日常工作中也能对你有所帮助。
今天我要问你的问题是,什么情况下 Java 程序会产生死锁?如何定位、修复?
典型回答
死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。
你可以利用下面的示例图理解基本的死锁问题:
定位死锁最常见的方式就是利用 jstack 等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁。如果是比较明显的死锁,往往 jstack 等就能直接定位,类似 JConsole 甚至可以在图形界面进行有限的死锁检测。
如果程序运行时发生了死锁,绝大多数情况下都是无法在线解决的,只能重启、修正程序本身问题。所以,代码开发阶段互相审查,或者利用工具进行预防性排查,往往也是很重要的。
考点分析
今天的问题偏向于实用场景大部分死锁本身并不难定位掌握基本思路和工具使用理解线程相关的基本概念比如各种线程状态和同步、锁、Latch 等并发工具,就已经足够解决大多数问题了。
针对死锁,面试官可以深入考察:
抛开字面上的概念,让面试者写一个可能死锁的程序,顺便也考察下基本的线程编程。
诊断死锁有哪些工具,如果是分布式环境,可能更关心能否用 API 实现吗?
后期诊断死锁还是挺痛苦的,经常加班,如何在编程中尽量避免一些典型场景的死锁,有其他工具辅助吗?
知识扩展
在分析开始之前,先以一个基本的死锁程序为例,我在这里只用了两个嵌套的 synchronized 去获取锁,具体如下:
public class DeadLockSample extends Thread {
private String first;
private String second;
public DeadLockSample(String name, String first, String second) {
super(name);
this.first = first;
this.second = second;
}
public void run() {
synchronized (first) {
System.out.println(this.getName() + " obtained: " + first);
try {
Thread.sleep(1000L);
synchronized (second) {
System.out.println(this.getName() + " obtained: " + second);
}
} catch (InterruptedException e) {
// Do nothing
}
}
}
public static void main(String[] args) throws InterruptedException {
String lockA = "lockA";
String lockB = "lockB";
DeadLockSample t1 = new DeadLockSample("Thread1", lockA, lockB);
DeadLockSample t2 = new DeadLockSample("Thread2", lockB, lockA);
t1.start();
t2.start();
t1.join();
t2.join();
}
}
这个程序编译执行后,几乎每次都可以重现死锁,请看下面截取的输出。另外,这里有个比较有意思的地方,为什么我先调用 Thread1 的 start但是 Thread2 却先打印出来了呢?这就是因为线程调度依赖于(操作系统)调度器,虽然你可以通过优先级之类进行影响,但是具体情况是不确定的。
下面来模拟问题定位,我就选取最常见的 jstack其他一些类似 JConsole 等图形化的工具,请自行查找。
首先,可以使用 jps 或者系统的 ps 命令、任务管理器等工具,确定进程 ID。
其次,调用 jstack 获取线程栈:
${JAVA_HOME}\bin\jstack your_pid
然后,分析得到的输出,具体片段如下:
最后,结合代码分析线程栈信息。上面这个输出非常明显,找到处于 BLOCKED 状态的线程按照试图获取waiting的锁 ID请看我标记为相同颜色的数字查找很快就定位问题。 jstack 本身也会把类似的简单死锁抽取出来,直接打印出来。
在实际应用中,类死锁情况未必有如此清晰的输出,但是总体上可以理解为:
区分线程状态 -&gt; 查看等待目标 -&gt; 对比 Monitor 等持有状态
所以,理解线程基本状态和并发相关元素是定位问题的关键,然后配合程序调用栈结构,基本就可以定位到具体的问题代码。
如果我们是开发自己的管理工具,需要用更加程序化的方式扫描服务进程、定位死锁,可以考虑使用 Java 提供的标准管理 APIThreadMXBean其直接就提供了 findDeadlockedThreads() 方法用于定位。为方便说明,我修改了 DeadLockSample请看下面的代码片段。
public static void main(String[] args) throws InterruptedException {
ThreadMXBean mbean = ManagementFactory.getThreadMXBean();
Runnable dlCheck = new Runnable() {
@Override
public void run() {
long[] threadIds = mbean.findDeadlockedThreads();
if (threadIds != null) {
ThreadInfo[] threadInfos = mbean.getThreadInfo(threadIds);
System.out.println("Detected deadlock threads:");
for (ThreadInfo threadInfo : threadInfos) {
System.out.println(threadInfo.getThreadName());
}
}
}
};
ScheduledExecutorService scheduler =Executors.newScheduledThreadPool(1);
// 稍等5秒然后每10秒进行一次死锁扫描
scheduler.scheduleAtFixedRate(dlCheck, 5L, 10L, TimeUnit.SECONDS);
// 死锁样例代码…
}
重新编译执行,你就能看到死锁被定位到的输出。在实际应用中,就可以据此收集进一步的信息,然后进行预警等后续处理。但是要注意的是,对线程进行快照本身是一个相对重量级的操作,还是要慎重选择频度和时机。
如何在编程中尽量预防死锁呢?
首先,我们来总结一下前面例子中死锁的产生包含哪些基本元素。基本上死锁的发生是因为:
互斥条件,类似 Java 中 Monitor 都是独占的,要么是我用,要么是你用。
互斥条件是长期持有的,在使用结束之前,自己不会释放,也不能被其他线程抢占。
循环依赖关系,两个或者多个个体之间出现了锁的链条环。
所以,我们可以据此分析可能的避免死锁的思路和方法。
第一种方法
如果可能的话,尽量避免使用多个锁,并且只有需要时才持有锁。否则,即使是非常精通并发编程的工程师,也难免会掉进坑里,嵌套的 synchronized 或者 lock 非常容易出问题。
我举个例子, Java NIO 的实现代码向来以锁多著称,一个原因是,其本身模型就非常复杂,某种程度上是不得不如此;另外是在设计时,考虑到既要支持阻塞模式,又要支持非阻塞模式。直接结果就是,一些基本操作如 connect需要操作三个锁以上在最近的一个 JDK 改进中,就发生了死锁现象。
我将其简化为下面的伪代码,问题是暴露在 HTTP/2 客户端中,这是个非常现代的反应式风格的 API非常推荐学习使用。
/// Thread HttpClient-6-SelectorManager:
readLock.lock();
writeLock.lock();
// 持有readLock/writeLock调用close需要获得closeLock
close();
// Thread HttpClient-6-Worker-2 持有closeLock
implCloseSelectableChannel (); //想获得readLock
在 close 发生时, HttpClient-6-SelectorManager 线程持有 readLock/writeLock试图获得 closeLock与此同时另一个 HttpClient-6-Worker-2 线程,持有 closeLock试图获得 readLock这就不可避免地进入了死锁。
这里比较难懂的地方在于closeLock 的持有状态(就是我标记为绿色的部分)并没有在线程栈中显示出来,请参考我在下图中标记的部分。
更加具体来说请查看SocketChannelImpl的 663 行,对比 implCloseSelectableChannel() 方法实现和AbstractInterruptibleChannel.close()在 109 行的代码,这里就不展示代码了。
所以,从程序设计的角度反思,如果我们赋予一段程序太多的职责,出现“既要…又要…”的情况时,可能就需要我们审视下设计思路或目的是否合理了。对于类库,因为其基础、共享的定位,比应用开发往往更加令人苦恼,需要仔细斟酌之间的平衡。
第二种方法
如果必须使用多个锁,尽量设计好锁的获取顺序,这个说起来简单,做起来可不容易,你可以参看著名的银行家算法。
一般的情况,我建议可以采取些简单的辅助手段,比如:
将对象(方法)和锁之间的关系,用图形化的方式表示分别抽取出来,以今天最初讲的死锁为例,因为是调用了同一个线程所以更加简单。
然后根据对象之间组合、调用的关系对比和组合,考虑可能调用时序。
按照可能时序合并,发现可能死锁的场景。
第三种方法
使用带超时的方法,为程序带来更多可控性。
类似 Object.wait(…) 或者 CountDownLatch.await(…),都支持所谓的 timed_wait我们完全可以就不假定该锁一定会获得指定超时时间并为无法得到锁时准备退出逻辑。
并发 Lock 实现,如 ReentrantLock 还支持非阻塞式的获取锁操作 tryLock()这是一个插队行为barging并不在乎等待的公平性如果执行时对象恰好没有被独占则直接获取锁。有时我们希望条件允许就尝试插队不然就按照现有公平性规则等待一般采用下面的方法
if (lock.tryLock() || lock.tryLock(timeout, unit)) {
// ...
}
第四种方法
业界也有一些其他方面的尝试,比如通过静态代码分析(如 FindBugs去查找固定的模式进而定位可能的死锁或者竞争情况。实践证明这种方法也有一定作用请参考相关文档。
除了典型应用中的死锁场景其实还有一些更令人头疼的死锁比如类加载过程发生的死锁尤其是在框架大量使用自定义类加载时因为往往不是在应用本身的代码库中jstack 等工具也不见得能够显示全部锁信息所以处理起来比较棘手。对此Java 有官方文档进行了详细解释,并针对特定情况提供了相应 JVM 参数和基本原则。
今天,我从样例程序出发,介绍了死锁产生原因,并帮你熟悉了排查死锁基本工具的使用和典型思路,最后结合实例介绍了实际场景中的死锁分析方法与预防措施,希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,有时候并不是阻塞导致的死锁,只是某个线程进入了死循环,导致其他线程一直等待,这种问题如何诊断呢?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,226 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 什么情况下Java程序会产生死锁如何定位、修复
今天,我会介绍一些日常开发中类似线程死锁等问题的排查经验,并选择一两个我自己修复过或者诊断过的核心类库死锁问题作为例子,希望不仅能在面试时,包括在日常工作中也能对你有所帮助。
今天我要问你的问题是什么情况下Java程序会产生死锁如何定位、修复
典型回答
死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。
你可以利用下面的示例图理解基本的死锁问题:
定位死锁最常见的方式就是利用jstack等工具获取线程栈然后定位互相之间的依赖关系进而找到死锁。如果是比较明显的死锁往往jstack等就能直接定位类似JConsole甚至可以在图形界面进行有限的死锁检测。
如果程序运行时发生了死锁,绝大多数情况下都是无法在线解决的,只能重启、修正程序本身问题。所以,代码开发阶段互相审查,或者利用工具进行预防性排查,往往也是很重要的。
考点分析
今天的问题偏向于实用场景大部分死锁本身并不难定位掌握基本思路和工具使用理解线程相关的基本概念比如各种线程状态和同步、锁、Latch等并发工具就已经足够解决大多数问题了。
针对死锁,面试官可以深入考察:
抛开字面上的概念,让面试者写一个可能死锁的程序,顺便也考察下基本的线程编程。
诊断死锁有哪些工具如果是分布式环境可能更关心能否用API实现吗
后期诊断死锁还是挺痛苦的,经常加班,如何在编程中尽量避免一些典型场景的死锁,有其他工具辅助吗?
知识扩展
在分析开始之前先以一个基本的死锁程序为例我在这里只用了两个嵌套的synchronized去获取锁具体如下
public class DeadLockSample extends Thread {
private String first;
private String second;
public DeadLockSample(String name, String first, String second) {
super(name);
this.first = first;
this.second = second;
}
public void run() {
synchronized (first) {
System.out.println(this.getName() + " obtained: " + first);
try {
Thread.sleep(1000L);
synchronized (second) {
System.out.println(this.getName() + " obtained: " + second);
}
} catch (InterruptedException e) {
// Do nothing
}
}
}
public static void main(String[] args) throws InterruptedException {
String lockA = "lockA";
String lockB = "lockB";
DeadLockSample t1 = new DeadLockSample("Thread1", lockA, lockB);
DeadLockSample t2 = new DeadLockSample("Thread2", lockB, lockA);
t1.start();
t2.start();
t1.join();
t2.join();
}
}
这个程序编译执行后几乎每次都可以重现死锁请看下面截取的输出。另外这里有个比较有意思的地方为什么我先调用Thread1的start但是Thread2却先打印出来了呢这就是因为线程调度依赖于操作系统调度器虽然你可以通过优先级之类进行影响但是具体情况是不确定的。
下面来模拟问题定位我就选取最常见的jstack其他一些类似JConsole等图形化的工具请自行查找。
首先可以使用jps或者系统的ps命令、任务管理器等工具确定进程ID。
其次调用jstack获取线程栈
${JAVA_HOME}\bin\jstack your_pid
然后,分析得到的输出,具体片段如下:
最后结合代码分析线程栈信息。上面这个输出非常明显找到处于BLOCKED状态的线程按照试图获取waiting的锁ID请看我标记为相同颜色的数字查找很快就定位问题。 jstack本身也会把类似的简单死锁抽取出来直接打印出来。
在实际应用中,类死锁情况未必有如此清晰的输出,但是总体上可以理解为:
区分线程状态 -> 查看等待目标 -> 对比Monitor等持有状态
所以,理解线程基本状态和并发相关元素是定位问题的关键,然后配合程序调用栈结构,基本就可以定位到具体的问题代码。
如果我们是开发自己的管理工具需要用更加程序化的方式扫描服务进程、定位死锁可以考虑使用Java提供的标准管理APIThreadMXBean其直接就提供了findDeadlockedThreads()方法用于定位。为方便说明我修改了DeadLockSample请看下面的代码片段。
public static void main(String[] args) throws InterruptedException {
ThreadMXBean mbean = ManagementFactory.getThreadMXBean();
Runnable dlCheck = new Runnable() {
@Override
public void run() {
long[] threadIds = mbean.findDeadlockedThreads();
if (threadIds != null) {
ThreadInfo[] threadInfos = mbean.getThreadInfo(threadIds);
System.out.println("Detected deadlock threads:");
for (ThreadInfo threadInfo : threadInfos) {
System.out.println(threadInfo.getThreadName());
}
}
}
};
ScheduledExecutorService scheduler =Executors.newScheduledThreadPool(1);
// 稍等5秒然后每10秒进行一次死锁扫描
scheduler.scheduleAtFixedRate(dlCheck, 5L, 10L, TimeUnit.SECONDS);
// 死锁样例代码…
}
重新编译执行,你就能看到死锁被定位到的输出。在实际应用中,就可以据此收集进一步的信息,然后进行预警等后续处理。但是要注意的是,对线程进行快照本身是一个相对重量级的操作,还是要慎重选择频度和时机。
如何在编程中尽量预防死锁呢?
首先,我们来总结一下前面例子中死锁的产生包含哪些基本元素。基本上死锁的发生是因为:
互斥条件类似Java中Monitor都是独占的要么是我用要么是你用。
互斥条件是长期持有的,在使用结束之前,自己不会释放,也不能被其他线程抢占。
循环依赖关系,两个或者多个个体之间出现了锁的链条环。
所以,我们可以据此分析可能的避免死锁的思路和方法。
第一种方法
如果可能的话尽量避免使用多个锁并且只有需要时才持有锁。否则即使是非常精通并发编程的工程师也难免会掉进坑里嵌套的synchronized或者lock非常容易出问题。
我举个例子, Java NIO的实现代码向来以锁多著称一个原因是其本身模型就非常复杂某种程度上是不得不如此另外是在设计时考虑到既要支持阻塞模式又要支持非阻塞模式。直接结果就是一些基本操作如connect需要操作三个锁以上在最近的一个JDK改进中就发生了死锁现象。
我将其简化为下面的伪代码问题是暴露在HTTP/2客户端中这是个非常现代的反应式风格的API非常推荐学习使用。
/// Thread HttpClient-6-SelectorManager:
readLock.lock();
writeLock.lock();
// 持有readLock/writeLock调用close需要获得closeLock
close();
// Thread HttpClient-6-Worker-2 持有closeLock
implCloseSelectableChannel (); //想获得readLock
在close发生时 HttpClient-6-SelectorManager线程持有readLock/writeLock试图获得closeLock与此同时另一个HttpClient-6-Worker-2线程持有closeLock试图获得readLock这就不可避免地进入了死锁。
这里比较难懂的地方在于closeLock的持有状态就是我标记为绿色的部分并没有在线程栈中显示出来请参考我在下图中标记的部分。
-
更加具体来说请查看SocketChannelImpl的663行对比implCloseSelectableChannel()方法实现和AbstractInterruptibleChannel.close()在109行的代码这里就不展示代码了。
所以,从程序设计的角度反思,如果我们赋予一段程序太多的职责,出现“既要…又要…”的情况时,可能就需要我们审视下设计思路或目的是否合理了。对于类库,因为其基础、共享的定位,比应用开发往往更加令人苦恼,需要仔细斟酌之间的平衡。
第二种方法
如果必须使用多个锁,尽量设计好锁的获取顺序,这个说起来简单,做起来可不容易,你可以参看著名的银行家算法。
一般的情况,我建议可以采取些简单的辅助手段,比如:
将对象(方法)和锁之间的关系,用图形化的方式表示分别抽取出来,以今天最初讲的死锁为例,因为是调用了同一个线程所以更加简单。
然后根据对象之间组合、调用的关系对比和组合,考虑可能调用时序。
按照可能时序合并,发现可能死锁的场景。
-
第三种方法
使用带超时的方法,为程序带来更多可控性。
类似Object.wait(…)或者CountDownLatch.await(…)都支持所谓的timed_wait我们完全可以就不假定该锁一定会获得指定超时时间并为无法得到锁时准备退出逻辑。
并发Lock实现如ReentrantLock还支持非阻塞式的获取锁操作tryLock()这是一个插队行为barging并不在乎等待的公平性如果执行时对象恰好没有被独占则直接获取锁。有时我们希望条件允许就尝试插队不然就按照现有公平性规则等待一般采用下面的方法
if (lock.tryLock() || lock.tryLock(timeout, unit)) {
// ...
}
第四种方法
业界也有一些其他方面的尝试比如通过静态代码分析如FindBugs去查找固定的模式进而定位可能的死锁或者竞争情况。实践证明这种方法也有一定作用请参考相关文档。
除了典型应用中的死锁场景其实还有一些更令人头疼的死锁比如类加载过程发生的死锁尤其是在框架大量使用自定义类加载时因为往往不是在应用本身的代码库中jstack等工具也不见得能够显示全部锁信息所以处理起来比较棘手。对此Java有官方文档进行了详细解释并针对特定情况提供了相应JVM参数和基本原则。
今天,我从样例程序出发,介绍了死锁产生原因,并帮你熟悉了排查死锁基本工具的使用和典型思路,最后结合实例介绍了实际场景中的死锁分析方法与预防措施,希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,有时候并不是阻塞导致的死锁,只是某个线程进入了死循环,导致其他线程一直等待,这种问题如何诊断呢?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,303 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 Java并发包提供了哪些并发工具类-极客时间
通过前面的学习,我们一起回顾了线程、锁等各种并发编程的基本元素,也逐步涉及了 Java 并发包中的部分内容,相信经过前面的热身,我们能够更快地理解 Java 并发包。
今天我要问你的问题是Java 并发包提供了哪些并发工具类?
典型回答
我们通常所说的并发包也就是 java.util.concurrent 及其子包,集中了 Java 并发的各种基础工具类,具体主要包括几个方面:
提供了比 synchronized 更加高级的各种同步结构,包括 CountDownLatch、CyclicBarrier、Semaphore 等,可以实现更加丰富的多线程操作,比如利用 Semaphore 作为资源控制器,限制同时进行工作的线程数量。
各种线程安全的容器,比如最常见的 ConcurrentHashMap、有序的 ConcurrentSkipListMap或者通过类似快照机制实现线程安全的动态数组 CopyOnWriteArrayList 等。
各种并发队列实现,如各种 BlockingQueue 实现,比较典型的 ArrayBlockingQueue、 SynchronousQueue 或针对特定场景的 PriorityBlockingQueue 等。
强大的 Executor 框架,可以创建各种不同类型的线程池,调度任务运行等,绝大部分情况下,不再需要自己从头实现线程池和任务调度器。
考点分析
这个题目主要考察你对并发包了解程度,以及是否有实际使用经验。我们进行多线程编程,无非是达到几个目的:
利用多线程提高程序的扩展能力,以达到业务对吞吐量的要求。
协调线程间调度、交互,以完成业务逻辑。
线程间传递数据和状态,这同样是实现业务逻辑的需要。
所以,这道题目只能算作简单的开始,往往面试官还会进一步考察如何利用并发包实现某个特定的用例,分析实现的优缺点等。
如果你在这方面的基础比较薄弱,我的建议是:
从总体上,把握住几个主要组成部分(前面回答中已经简要介绍)。
理解具体设计、实现和能力。
再深入掌握一些比较典型工具类的适用场景、用法甚至是原理,并熟练写出典型的代码用例。
掌握这些通常就够用了,毕竟并发包提供了方方面面的工具,其实很少有机会能在应用中全面使用过,扎实地掌握核心功能就非常不错了。真正特别深入的经验,还是得靠在实际场景中踩坑来获得。
知识扩展
首先,我们来看看并发包提供的丰富同步结构。前面几讲已经分析过各种不同的显式锁,今天我将专注于
CountDownLatch允许一个或多个线程等待某些操作完成。
CyclicBarrier一种辅助性的同步结构允许多个线程等待到达某个屏障。
SemaphoreJava 版本的信号量实现。
Java 提供了经典信号量Semaphore的实现它通过控制一定数量的允许permit的方式来达到限制通用资源访问的目的。你可以想象一下这个场景在车站、机场等出租车时当很多空出租车就位时为防止过度拥挤调度员指挥排队等待坐车的队伍一次进来 5 个人上车,等这 5 个人坐车出发,再放进去下一批,这和 Semaphore 的工作原理有些类似。
你可以试试使用 Semaphore 来模拟实现这个调度过程:
import java.util.concurrent.Semaphore;
public class UsualSemaphoreSample {
public static void main(String[] args) throws InterruptedException {
System.out.println("Action...GO!");
Semaphore semaphore = new Semaphore(5);
for (int i = 0; i < 10; i++) {
Thread t = new Thread(new SemaphoreWorker(semaphore));
t.start();
}
}
}
class SemaphoreWorker implements Runnable {
private String name;
private Semaphore semaphore;
public SemaphoreWorker(Semaphore semaphore) {
this.semaphore = semaphore;
}
@Override
public void run() {
try {
log("is waiting for a permit!");
semaphore.acquire();
log("acquired a permit!");
log("executed!");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
log("released a permit!");
semaphore.release();
}
}
private void log(String msg){
if (name == null) {
name = Thread.currentThread().getName();
}
System.out.println(name + " " + msg);
}
}
这段代码是比较典型的 Semaphore 示例其逻辑是线程试图获得工作允许得到许可则进行任务然后释放许可这时等待许可的其他线程就可获得许可进入工作状态直到全部处理结束编译运行我们就能看到 Semaphore 的允许机制对工作线程的限制
但是从具体节奏来看其实并不符合我们前面场景的需求因为本例中 Semaphore 的用法实际是保证一直有 5 个人可以试图乘车如果有 1 个人出发了立即就有排队的人获得许可而这并不完全符合我们前面的要求
那么我再修改一下演示个非典型的 Semaphore 用法
import java.util.concurrent.Semaphore;
public class AbnormalSemaphoreSample {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(0);
for (int i = 0; i < 10; i++) {
Thread t = new Thread(new MyWorker(semaphore));
t.start();
}
System.out.println("Action...GO!");
semaphore.release(5);
System.out.println("Wait for permits off");
while (semaphore.availablePermits()!=0) {
Thread.sleep(100L);
}
System.out.println("Action...GO again!");
semaphore.release(5);
}
}
class MyWorker implements Runnable {
private Semaphore semaphore;
public MyWorker(Semaphore semaphore) {
this.semaphore = semaphore;
}
@Override
public void run() {
try {
semaphore.acquire();
System.out.println("Executed!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
注意上面的代码更侧重的是演示 Semaphore 的功能以及局限性其实有很多线程编程中的反实践比如使用了 sleep 来协调任务执行而且使用轮询调用 availalePermits 来检测信号量获取情况这都是很低效并且脆弱的通常只是用在测试或者诊断场景
总的来说我们可以看出 Semaphore 就是个计数器其基本逻辑基于 acquire/release并没有太复杂的同步逻辑
如果 Semaphore 的数值被初始化为 1那么一个线程就可以通过 acquire 进入互斥状态本质上和互斥锁是非常相似的但是区别也非常明显比如互斥锁是有持有者的而对于 Semaphore 这种计数器结构虽然有类似功能但其实不存在真正意义的持有者除非我们进行扩展包装
下面来看看 CountDownLatch CyclicBarrier它们的行为有一定的相似度经常会被考察二者有什么区别我来简单总结一下
CountDownLatch 是不可以重置的所以无法重用 CyclicBarrier 则没有这种限制可以重用
CountDownLatch 的基本操作组合是 countDown/await调用 await 的线程阻塞等待 countDown 足够的次数不管你是在一个线程还是多个线程里 countDown只要次数足够即可所以就像 Brain Goetz 说过的CountDownLatch 操作的是事件
CyclicBarrier 的基本操作组合则就是 await当所有的伙伴parties都调用了 await才会继续进行任务并自动进行重置注意正常情况下CyclicBarrier 的重置都是自动发生的如果我们调用 reset 方法但还有线程在等待就会导致等待线程被打扰抛出 BrokenBarrierException 异常CyclicBarrier 侧重点是线程而不是调用事件它的典型应用场景是用来等待并发线程结束
如果用 CountDownLatch 去实现上面的排队场景该怎么做呢假设有 10 个人排队我们将其分成 5 个人一批通过 CountDownLatch 来协调批次你可以试试下面的示例代码
import java.util.concurrent.CountDownLatch;
public class LatchSample {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(6);
for (int i = 0; i < 5; i++) {
Thread t = new Thread(new FirstBatchWorker(latch));
t.start();
}
for (int i = 0; i < 5; i++) {
Thread t = new Thread(new SecondBatchWorker(latch));
t.start();
}
// 注意这里也是演示目的的逻辑并不是推荐的协调方式
while ( latch.getCount() != 1 ){
Thread.sleep(100L);
}
System.out.println("Wait for first batch finish");
latch.countDown();
}
}
class FirstBatchWorker implements Runnable {
private CountDownLatch latch;
public FirstBatchWorker(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
System.out.println("First batch executed!");
latch.countDown();
}
}
class SecondBatchWorker implements Runnable {
private CountDownLatch latch;
public SecondBatchWorker(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
try {
latch.await();
System.out.println("Second batch executed!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
CountDownLatch 的调度方式相对简单后一批次的线程进行 await等待前一批 countDown 足够多次这个例子也从侧面体现出了它的局限性虽然它也能够支持 10 个人排队的情况但是因为不能重用如果要支持更多人排队就不能依赖一个 CountDownLatch 进行了其编译运行输出如下
在实际应用中的条件依赖往往没有这么别扭CountDownLatch 用于线程间等待操作结束是非常简单普遍的用法通过 countDown/await 组合进行通信是很高效的通常不建议使用例子里那个循环等待方式
如果用 CyclicBarrier 来表达这个场景呢我们知道 CyclicBarrier 其实反映的是线程并行运行时的协调在下面的示例里从逻辑上5 个工作线程其实更像是代表了 5 个可以就绪的空车而不再是 5 个乘客对比前面 CountDownLatch 的例子更有助于我们区别它们的抽象模型请看下面的示例代码
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierSample {
public static void main(String[] args) throws InterruptedException {
CyclicBarrier barrier = new CyclicBarrier(5, new Runnable() {
@Override
public void run() {
System.out.println("Action...GO again!");
}
});
for (int i = 0; i < 5; i++) {
Thread t = new Thread(new CyclicWorker(barrier));
t.start();
}
}
static class CyclicWorker implements Runnable {
private CyclicBarrier barrier;
public CyclicWorker(CyclicBarrier barrier) {
this.barrier = barrier;
}
@Override
public void run() {
try {
for (int i=0; i<3 ; i++){
System.out.println("Executed!");
barrier.await();
}
} catch (BrokenBarrierException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
为了让输出更能表达运行时序我使用了 CyclicBarrier 特有的 barrierAction当屏障被触发时Java 会自动调度该动作因为 CyclicBarrier 会自动进行重置所以这个逻辑其实可以非常自然的支持更多排队人数其编译输出如下
Java 并发类库还提供了Phaser功能与 CountDownLatch 很接近但是它允许线程动态地注册到 Phaser 上面 CountDownLatch 显然是不能动态设置的Phaser 的设计初衷是实现多个线程类似步骤阶段场景的协调线程注册等待屏障条件触发进而协调彼此间行动具体请参考这个例子
接下来我来梳理下并发包里提供的线程安全 MapList Set首先请参考下面的类图
你可以看到总体上种类和结构还是比较简单的如果我们的应用侧重于 Map 放入或者获取的速度而不在乎顺序大多推荐使用 ConcurrentHashMap反之则使用 ConcurrentSkipListMap如果我们需要对大量数据进行非常频繁地修改ConcurrentSkipListMap 也可能表现出优势
我在前面的专栏谈到了普通无顺序场景选择 HashMap有顺序场景则可以选择类似 TreeMap 但是为什么并发容器里面没有 ConcurrentTreeMap
这是因为 TreeMap 要实现高效的线程安全是非常困难的它的实现基于复杂的红黑树为保证访问效率当我们插入或删除节点时会移动节点进行平衡操作这导致在并发场景中难以进行合理粒度的同步 SkipList 结构则要相对简单很多通过层次结构提高访问速度虽然不够紧凑空间使用有一定提高O(nlogn)但是在增删元素时线程安全的开销要好很多为了方便你理解 SkipList 的内部结构我画了一个示意图
关于两个 CopyOnWrite 容器其实 CopyOnWriteArraySet 是通过包装了 CopyOnWriteArrayList 来实现的所以在学习时我们可以专注于理解一种
首先CopyOnWrite 到底是什么意思呢它的原理是任何修改操作 addsetremove都会拷贝原数组修改后替换原来的数组通过这种防御性的方式实现另类的线程安全请看下面的代码片段我进行注释的地方可以清晰地理解其逻辑
public boolean add(E e) {
synchronized (lock) {
Object[] elements = getArray();
int len = elements.length;
// 拷贝
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
// 替换
setArray(newElements);
return true;
}
}
final void setArray(Object[] a) {
array = a;
}
所以这种数据结构相对比较适合读多写少的操作不然修改的开销还是非常明显的
今天我对 Java 并发包进行了总结并且结合实例分析了各种同步结构和部分线程安全容器希望对你有所帮助
一课一练
关于今天我们讨论的题目你做到心中有数了吗留给你的思考题是你使用过类似 CountDownLatch 的同步结构解决实际问题吗谈谈你的使用场景和心得
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习奖励礼券欢迎你与我一起讨论
你的朋友是不是也在准备面试呢你可以请朋友读”,把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,312 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 Java并发包提供了哪些并发工具类
通过前面的学习我们一起回顾了线程、锁等各种并发编程的基本元素也逐步涉及了Java并发包中的部分内容相信经过前面的热身我们能够更快地理解Java并发包。
今天我要问你的问题是Java并发包提供了哪些并发工具类
典型回答
我们通常所说的并发包也就是java.util.concurrent及其子包集中了Java并发的各种基础工具类具体主要包括几个方面
提供了比synchronized更加高级的各种同步结构包括CountDownLatch、CyclicBarrier、Semaphore等可以实现更加丰富的多线程操作比如利用Semaphore作为资源控制器限制同时进行工作的线程数量。
各种线程安全的容器比如最常见的ConcurrentHashMap、有序的ConcurrentSkipListMap或者通过类似快照机制实现线程安全的动态数组CopyOnWriteArrayList等。
各种并发队列实现如各种BlockingQueue实现比较典型的ArrayBlockingQueue、 SynchronousQueue或针对特定场景的PriorityBlockingQueue等。
强大的Executor框架可以创建各种不同类型的线程池调度任务运行等绝大部分情况下不再需要自己从头实现线程池和任务调度器。
考点分析
这个题目主要考察你对并发包了解程度,以及是否有实际使用经验。我们进行多线程编程,无非是达到几个目的:
利用多线程提高程序的扩展能力,以达到业务对吞吐量的要求。
协调线程间调度、交互,以完成业务逻辑。
线程间传递数据和状态,这同样是实现业务逻辑的需要。
所以,这道题目只能算作简单的开始,往往面试官还会进一步考察如何利用并发包实现某个特定的用例,分析实现的优缺点等。
如果你在这方面的基础比较薄弱,我的建议是:
从总体上,把握住几个主要组成部分(前面回答中已经简要介绍)。
理解具体设计、实现和能力。
再深入掌握一些比较典型工具类的适用场景、用法甚至是原理,并熟练写出典型的代码用例。
掌握这些通常就够用了,毕竟并发包提供了方方面面的工具,其实很少有机会能在应用中全面使用过,扎实地掌握核心功能就非常不错了。真正特别深入的经验,还是得靠在实际场景中踩坑来获得。
知识扩展
首先,我们来看看并发包提供的丰富同步结构。前面几讲已经分析过各种不同的显式锁,今天我将专注于
CountDownLatch允许一个或多个线程等待某些操作完成。
CyclicBarrier一种辅助性的同步结构允许多个线程等待到达某个屏障。
SemaphoreJava版本的信号量实现。
Java提供了经典信号量Semaphore的实现它通过控制一定数量的允许permit的方式来达到限制通用资源访问的目的。你可以想象一下这个场景在车站、机场等出租车时当很多空出租车就位时为防止过度拥挤调度员指挥排队等待坐车的队伍一次进来5个人上车等这5个人坐车出发再放进去下一批这和Semaphore的工作原理有些类似。
你可以试试使用Semaphore来模拟实现这个调度过程
import java.util.concurrent.Semaphore;
public class UsualSemaphoreSample {
public static void main(String[] args) throws InterruptedException {
System.out.println("Action...GO!");
Semaphore semaphore = new Semaphore(5);
for (int i = 0; i < 10; i++) {
Thread t = new Thread(new SemaphoreWorker(semaphore));
t.start();
}
}
}
class SemaphoreWorker implements Runnable {
private String name;
private Semaphore semaphore;
public SemaphoreWorker(Semaphore semaphore) {
this.semaphore = semaphore;
}
@Override
public void run() {
try {
log("is waiting for a permit!");
semaphore.acquire();
log("acquired a permit!");
log("executed!");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
log("released a permit!");
semaphore.release();
}
}
private void log(String msg){
if (name == null) {
name = Thread.currentThread().getName();
}
System.out.println(name + " " + msg);
}
}
这段代码是比较典型的Semaphore示例其逻辑是线程试图获得工作允许得到许可则进行任务然后释放许可这时等待许可的其他线程就可获得许可进入工作状态直到全部处理结束编译运行我们就能看到Semaphore的允许机制对工作线程的限制
但是从具体节奏来看其实并不符合我们前面场景的需求因为本例中Semaphore的用法实际是保证一直有5个人可以试图乘车如果有1个人出发了立即就有排队的人获得许可而这并不完全符合我们前面的要求
那么我再修改一下演示个非典型的Semaphore用法
import java.util.concurrent.Semaphore;
public class AbnormalSemaphoreSample {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(0);
for (int i = 0; i < 10; i++) {
Thread t = new Thread(new MyWorker(semaphore));
t.start();
}
System.out.println("Action...GO!");
semaphore.release(5);
System.out.println("Wait for permits off");
while (semaphore.availablePermits()!=0) {
Thread.sleep(100L);
}
System.out.println("Action...GO again!");
semaphore.release(5);
}
}
class MyWorker implements Runnable {
private Semaphore semaphore;
public MyWorker(Semaphore semaphore) {
this.semaphore = semaphore;
}
@Override
public void run() {
try {
semaphore.acquire();
System.out.println("Executed!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
注意上面的代码更侧重的是演示Semaphore的功能以及局限性其实有很多线程编程中的反实践比如使用了sleep来协调任务执行而且使用轮询调用availalePermits来检测信号量获取情况这都是很低效并且脆弱的通常只是用在测试或者诊断场景
总的来说我们可以看出Semaphore就是个计数器其基本逻辑基于acquire/release并没有太复杂的同步逻辑
如果Semaphore的数值被初始化为1那么一个线程就可以通过acquire进入互斥状态本质上和互斥锁是非常相似的但是区别也非常明显比如互斥锁是有持有者的而对于Semaphore这种计数器结构虽然有类似功能但其实不存在真正意义的持有者除非我们进行扩展包装
下面来看看CountDownLatch和CyclicBarrier它们的行为有一定的相似度经常会被考察二者有什么区别我来简单总结一下
CountDownLatch是不可以重置的所以无法重用而CyclicBarrier则没有这种限制可以重用
CountDownLatch的基本操作组合是countDown/await调用await的线程阻塞等待countDown足够的次数不管你是在一个线程还是多个线程里countDown只要次数足够即可所以就像Brain Goetz说过的CountDownLatch操作的是事件
CyclicBarrier的基本操作组合则就是await当所有的伙伴parties都调用了await才会继续进行任务并自动进行重置注意正常情况下CyclicBarrier的重置都是自动发生的如果我们调用reset方法但还有线程在等待就会导致等待线程被打扰抛出BrokenBarrierException异常CyclicBarrier侧重点是线程而不是调用事件它的典型应用场景是用来等待并发线程结束
如果用CountDownLatch去实现上面的排队场景该怎么做呢假设有10个人排队我们将其分成5个人一批通过CountDownLatch来协调批次你可以试试下面的示例代码
import java.util.concurrent.CountDownLatch;
public class LatchSample {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(6);
for (int i = 0; i < 5; i++) {
Thread t = new Thread(new FirstBatchWorker(latch));
t.start();
}
for (int i = 0; i < 5; i++) {
Thread t = new Thread(new SecondBatchWorker(latch));
t.start();
}
// 注意这里也是演示目的的逻辑并不是推荐的协调方式
while ( latch.getCount() != 1 ){
Thread.sleep(100L);
}
System.out.println("Wait for first batch finish");
latch.countDown();
}
}
class FirstBatchWorker implements Runnable {
private CountDownLatch latch;
public FirstBatchWorker(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
System.out.println("First batch executed!");
latch.countDown();
}
}
class SecondBatchWorker implements Runnable {
private CountDownLatch latch;
public SecondBatchWorker(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
try {
latch.await();
System.out.println("Second batch executed!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
CountDownLatch的调度方式相对简单后一批次的线程进行await等待前一批countDown足够多次这个例子也从侧面体现出了它的局限性虽然它也能够支持10个人排队的情况但是因为不能重用如果要支持更多人排队就不能依赖一个CountDownLatch进行了其编译运行输出如下
在实际应用中的条件依赖往往没有这么别扭CountDownLatch用于线程间等待操作结束是非常简单普遍的用法通过countDown/await组合进行通信是很高效的通常不建议使用例子里那个循环等待方式
如果用CyclicBarrier来表达这个场景呢我们知道CyclicBarrier其实反映的是线程并行运行时的协调在下面的示例里从逻辑上5个工作线程其实更像是代表了5个可以就绪的空车而不再是5个乘客对比前面CountDownLatch的例子更有助于我们区别它们的抽象模型请看下面的示例代码
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierSample {
public static void main(String[] args) throws InterruptedException {
CyclicBarrier barrier = new CyclicBarrier(5, new Runnable() {
@Override
public void run() {
System.out.println("Action...GO again!");
}
});
for (int i = 0; i < 5; i++) {
Thread t = new Thread(new CyclicWorker(barrier));
t.start();
}
}
static class CyclicWorker implements Runnable {
private CyclicBarrier barrier;
public CyclicWorker(CyclicBarrier barrier) {
this.barrier = barrier;
}
@Override
public void run() {
try {
for (int i=0; i<3 ; i++){
System.out.println("Executed!");
barrier.await();
}
} catch (BrokenBarrierException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
为了让输出更能表达运行时序我使用了CyclicBarrier特有的barrierAction当屏障被触发时Java会自动调度该动作因为CyclicBarrier会自动进行重置所以这个逻辑其实可以非常自然的支持更多排队人数其编译输出如下
Java并发类库还提供了Phaser功能与CountDownLatch很接近但是它允许线程动态地注册到Phaser上面而CountDownLatch显然是不能动态设置的Phaser的设计初衷是实现多个线程类似步骤阶段场景的协调线程注册等待屏障条件触发进而协调彼此间行动具体请参考这个例子
接下来我来梳理下并发包里提供的线程安全MapList和Set首先请参考下面的类图
你可以看到总体上种类和结构还是比较简单的如果我们的应用侧重于Map放入或者获取的速度而不在乎顺序大多推荐使用ConcurrentHashMap反之则使用ConcurrentSkipListMap如果我们需要对大量数据进行非常频繁地修改ConcurrentSkipListMap也可能表现出优势
我在前面的专栏谈到了普通无顺序场景选择HashMap有顺序场景则可以选择类似TreeMap等但是为什么并发容器里面没有ConcurrentTreeMap呢
这是因为TreeMap要实现高效的线程安全是非常困难的它的实现基于复杂的红黑树为保证访问效率当我们插入或删除节点时会移动节点进行平衡操作这导致在并发场景中难以进行合理粒度的同步而SkipList结构则要相对简单很多通过层次结构提高访问速度虽然不够紧凑空间使用有一定提高O(nlogn)但是在增删元素时线程安全的开销要好很多为了方便你理解SkipList的内部结构我画了一个示意图
关于两个CopyOnWrite容器其实CopyOnWriteArraySet是通过包装了CopyOnWriteArrayList来实现的所以在学习时我们可以专注于理解一种
首先CopyOnWrite到底是什么意思呢它的原理是任何修改操作如addsetremove都会拷贝原数组修改后替换原来的数组通过这种防御性的方式实现另类的线程安全请看下面的代码片段我进行注释的地方可以清晰地理解其逻辑
public boolean add(E e) {
synchronized (lock) {
Object[] elements = getArray();
int len = elements.length;
// 拷贝
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
// 替换
setArray(newElements);
return true;
}
}
final void setArray(Object[] a) {
array = a;
}
所以这种数据结构相对比较适合读多写少的操作不然修改的开销还是非常明显的
今天我对Java并发包进行了总结并且结合实例分析了各种同步结构和部分线程安全容器希望对你有所帮助
一课一练
关于今天我们讨论的题目你做到心中有数了吗留给你的思考题是你使用过类似CountDownLatch的同步结构解决实际问题吗谈谈你的使用场景和心得
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习奖励礼券欢迎你与我一起讨论
你的朋友是不是也在准备面试呢你可以请朋友读”,把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,237 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别-极客时间
在上一讲中,我分析了 Java 并发包中的部分内容今天我来介绍一下线程安全队列。Java 标准库提供了非常多的线程安全队列,很容易混淆。
今天我要问你的问题是,并发包中的 ConcurrentLinkedQueue 和 LinkedBlockingQueue 有什么区别?
典型回答
有时候我们把并发包下面的所有容器都习惯叫作并发容器,但是严格来讲,类似 ConcurrentLinkedQueue 这种“Concurrent*”容器,才是真正代表并发。
关于问题中它们的区别:
Concurrent 类型基于 lock-free在常见的多线程访问场景一般可以提供较高吞吐量。
而 LinkedBlockingQueue 内部则是基于锁,并提供了 BlockingQueue 的等待性方法。
不知道你有没有注意到java.util.concurrent 包提供的容器Queue、List、Set、Map从命名上可以大概区分为 Concurrent*、CopyOnWrite和 Blocking等三类同样是线程安全容器可以简单认为
Concurrent 类型没有类似 CopyOnWrite 之类容器相对较重的修改开销。
但是凡事都是有代价的Concurrent 往往提供了较低的遍历一致性。你可以这样理解所谓的弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历。
与弱一致性对应的就是我介绍过的同步容器常见的行为“fail-fast”也就是检测到容器在遍历过程中发生了修改则抛出 ConcurrentModificationException不再继续遍历。
弱一致性的另外一个体现是size 等操作准确性是有限的,未必是 100% 准确。
与此同时,读取的性能具有一定的不确定性。
考点分析
今天的问题是又是一个引子,考察你是否了解并发包内部不同容器实现的设计目的和实现区别。
队列是非常重要的数据结构我们日常开发中很多线程间数据传递都要依赖于它Executor 框架提供的各种线程池,同样无法离开队列。面试官可以从不同角度考察,比如:
哪些队列是有界的,哪些是无界的?(很多同学反馈了这个问题)
针对特定场景需求,如何选择合适的队列实现?
从源码的角度,常见的线程安全队列是如何实现的,并进行了哪些改进以提高性能表现?
为了能更好地理解这一讲,需要你掌握一些基本的队列本身和数据结构方面知识,如果这方面知识比较薄弱,《数据结构与算法分析》是一本比较全面的参考书,专栏还是尽量专注于 Java 领域的特性。
知识扩展
线程安全队列一览
我在【专栏第 8 讲】中介绍过,常见的集合中如 LinkedList 是个 Deque只不过不是线程安全的。下面这张图是 Java 并发类库提供的各种各样的线程安全队列实现,注意,图中并未将非线程安全部分包含进来。
我们可以从不同的角度进行分类从基本的数据结构的角度分析有两个特别的Deque实现ConcurrentLinkedDeque 和 LinkedBlockingDeque。Deque 的侧重点是支持对队列头尾都进行插入和删除,所以提供了特定的方法,如:
尾部插入时需要的addLast(e)、offerLast(e)。
尾部删除所需要的removeLast()、pollLast()。
从上面这些角度,能够理解 ConcurrentLinkedDeque 和 LinkedBlockingQueue 的主要功能区别,也就足够日常开发的需要了。但是如果我们深入一些,通常会更加关注下面这些方面。
从行为特征来看,绝大部分 Queue 都是实现了 BlockingQueue 接口。在常规队列操作基础上Blocking 意味着其提供了特定的等待性操作获取时take等待元素进队或者插入时put等待队列出现空位。
/**
* 获取并移除队列头结点,如果必要,其会等待直到队列出现元素
*/
E take() throws InterruptedException;
/**
* 插入元素,如果队列已满,则等待直到队列出现空闲空间
*/
void put(E e) throws InterruptedException;
另一个 BlockingQueue 经常被考察的点就是是否有界Bounded、Unbounded这一点也往往会影响我们在应用开发中的选择我这里简单总结一下。
ArrayBlockingQueue 是最典型的的有界队列,其内部以 final 的数组保存数据,数组的大小就决定了队列的边界,所以我们在创建 ArrayBlockingQueue 时,都要指定容量,如
public ArrayBlockingQueue(int capacity, boolean fair)
LinkedBlockingQueue容易被误解为无边界但其实其行为和内部代码都是基于有界的逻辑实现的只不过如果我们没有在创建队列时就指定容量那么其容量限制就自动被设置为 Integer.MAX_VALUE成为了无界队列。
SynchronousQueue这是一个非常奇葩的队列实现每个删除操作都要等待插入操作反之每个插入操作也都要等待删除动作。那么这个队列的容量是多少呢是 1 吗?其实不是的,其内部容量是 0。
PriorityBlockingQueue 是无边界的优先队列,虽然严格意义上来讲,其大小总归是要受系统资源影响。
DelayedQueue 和 LinkedTransferQueue 同样是无边界的队列。对于无边界的队列,有一个自然的结果,就是 put 操作永远也不会发生其他 BlockingQueue 的那种等待情况。
如果我们分析不同队列的底层实现BlockingQueue 基本都是基于锁实现,一起来看看典型的 LinkedBlockingQueue。
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
我在介绍 ReentrantLock 的条件变量用法的时候分析过 ArrayBlockingQueue不知道你有没有注意到其条件变量与 LinkedBlockingQueue 版本的实现是有区别的。notEmpty、notFull 都是同一个再入锁的条件变量,而 LinkedBlockingQueue 则改进了锁操作的粒度,头、尾操作使用不同的锁,所以在通用场景下,它的吞吐量相对要更好一些。
下面的 take 方法与 ArrayBlockingQueue 中的实现,也是有不同的,由于其内部结构是链表,需要自己维护元素数量值,请参考下面的代码。
public E take() throws InterruptedException {
final E x;
final int c;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
notEmpty.await();
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
类似 ConcurrentLinkedQueue 等,则是基于 CAS 的无锁技术,不需要在每个操作时使用锁,所以扩展性表现要更加优异。
相对比较另类的 SynchronousQueue在 Java 6 中,其实现发生了非常大的变化,利用 CAS 替换掉了原本基于锁的逻辑,同步开销比较小。它是 Executors.newCachedThreadPool() 的默认队列。
队列使用场景与典型用例
在实际开发中,我提到过 Queue 被广泛使用在生产者 - 消费者场景,比如利用 BlockingQueue 来实现,由于其提供的等待机制,我们可以少操心很多协调工作,你可以参考下面样例代码:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class ConsumerProducer {
public static final String EXIT_MSG = "Good bye!";
public static void main(String[] args) {
// 使用较小的队列,以更好地在输出中展示其影响
BlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
Producer producer = new Producer(queue);
Consumer consumer = new Consumer(queue);
new Thread(producer).start();
new Thread(consumer).start();
}
static class Producer implements Runnable {
private BlockingQueue<String> queue;
public Producer(BlockingQueue<String> q) {
this.queue = q;
}
@Override
public void run() {
for (int i = 0; i < 20; i++) {
try{
Thread.sleep(5L);
String msg = "Message" + i;
System.out.println("Produced new item: " + msg);
queue.put(msg);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
System.out.println("Time to say good bye!");
queue.put(EXIT_MSG);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
static class Consumer implements Runnable{
private BlockingQueue<String> queue;
public Consumer(BlockingQueue<String> q){
this.queue=q;
}
@Override
public void run() {
try{
String msg;
while(!EXIT_MSG.equalsIgnoreCase( (msg = queue.take()))){
System.out.println("Consumed item: " + msg);
Thread.sleep(10L);
}
System.out.println("Got exit message, bye!");
}catch(InterruptedException e) {
e.printStackTrace();
}
}
}
}
上面是一个典型的生产者 - 消费者样例,如果使用非 Blocking 的队列,那么我们就要自己去实现轮询、条件判断(如检查 poll 返回值是否 null等逻辑如果没有特别的场景要求Blocking 实现起来代码更加简单、直观。
前面介绍了各种队列实现,在日常的应用开发中,如何进行选择呢?
以 LinkedBlockingQueue、ArrayBlockingQueue 和 SynchronousQueue 为例,我们一起来分析一下,根据需求可以从很多方面考量:
考虑应用场景中对队列边界的要求。ArrayBlockingQueue 是有明确的容量限制的,而 LinkedBlockingQueue 则取决于我们是否在创建时指定SynchronousQueue 则干脆不能缓存任何元素。
从空间利用角度,数组结构的 ArrayBlockingQueue 要比 LinkedBlockingQueue 紧凑,因为其不需要创建所谓节点,但是其初始分配阶段就需要一段连续的空间,所以初始内存需求更大。
通用场景中LinkedBlockingQueue 的吞吐量一般优于 ArrayBlockingQueue因为它实现了更加细粒度的锁操作。
ArrayBlockingQueue 实现比较简单,性能更好预测,属于表现稳定的“选手”。
如果我们需要实现的是两个线程之间接力性handoff的场景按照【专栏上一讲】的例子你可能会选择 CountDownLatch但是SynchronousQueue也是完美符合这种场景的而且线程间协调和数据传输统一起来代码更加规范。
可能令人意外的是,很多时候 SynchronousQueue 的性能表现,往往大大超过其他实现,尤其是在队列元素较小的场景。
今天我分析了 Java 中让人眼花缭乱的各种线程安全队列,试图从几个角度,让每个队列的特点更加明确,进而希望减少你在日常工作中使用时的困扰。
一课一练
关于今天我们讨论的题目你做到心中有数了吗? 今天的内容侧重于 Java 自身的角度,面试官也可能从算法的角度来考察,所以今天留给你的思考题是,指定某种结构,比如栈,用它实现一个 BlockingQueue实现思路是怎样的呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,250 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别
在上一讲中我分析了Java并发包中的部分内容今天我来介绍一下线程安全队列。Java标准库提供了非常多的线程安全队列很容易混淆。
今天我要问你的问题是并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别
典型回答
有时候我们把并发包下面的所有容器都习惯叫作并发容器但是严格来讲类似ConcurrentLinkedQueue这种“Concurrent*”容器,才是真正代表并发。
关于问题中它们的区别:
Concurrent类型基于lock-free在常见的多线程访问场景一般可以提供较高吞吐量。
而LinkedBlockingQueue内部则是基于锁并提供了BlockingQueue的等待性方法。
不知道你有没有注意到java.util.concurrent包提供的容器Queue、List、Set、Map从命名上可以大概区分为Concurrent*、CopyOnWrite_和Blocking_等三类同样是线程安全容器可以简单认为
Concurrent类型没有类似CopyOnWrite之类容器相对较重的修改开销。
但是凡事都是有代价的Concurrent往往提供了较低的遍历一致性。你可以这样理解所谓的弱一致性例如当利用迭代器遍历时如果容器发生修改迭代器仍然可以继续进行遍历。
与弱一致性对应的就是我介绍过的同步容器常见的行为“fail-fast”也就是检测到容器在遍历过程中发生了修改则抛出ConcurrentModificationException不再继续遍历。
弱一致性的另外一个体现是size等操作准确性是有限的未必是100%准确。
与此同时,读取的性能具有一定的不确定性。
考点分析
今天的问题是又是一个引子,考察你是否了解并发包内部不同容器实现的设计目的和实现区别。
队列是非常重要的数据结构我们日常开发中很多线程间数据传递都要依赖于它Executor框架提供的各种线程池同样无法离开队列。面试官可以从不同角度考察比如
哪些队列是有界的,哪些是无界的?(很多同学反馈了这个问题)
针对特定场景需求,如何选择合适的队列实现?
从源码的角度,常见的线程安全队列是如何实现的,并进行了哪些改进以提高性能表现?
为了能更好地理解这一讲需要你掌握一些基本的队列本身和数据结构方面知识如果这方面知识比较薄弱《数据结构与算法分析》是一本比较全面的参考书专栏还是尽量专注于Java领域的特性。
知识扩展
线程安全队列一览
我在[专栏第8讲]中介绍过常见的集合中如LinkedList是个Deque只不过不是线程安全的。下面这张图是Java并发类库提供的各种各样的线程安全队列实现注意图中并未将非线程安全部分包含进来。
我们可以从不同的角度进行分类从基本的数据结构的角度分析有两个特别的Deque实现ConcurrentLinkedDeque和LinkedBlockingDeque。Deque的侧重点是支持对队列头尾都进行插入和删除所以提供了特定的方法如:
尾部插入时需要的addLast(e)、offerLast(e)。
尾部删除所需要的removeLast()、pollLast()。
从上面这些角度能够理解ConcurrentLinkedDeque和LinkedBlockingQueue的主要功能区别也就足够日常开发的需要了。但是如果我们深入一些通常会更加关注下面这些方面。
从行为特征来看绝大部分Queue都是实现了BlockingQueue接口。在常规队列操作基础上Blocking意味着其提供了特定的等待性操作获取时take等待元素进队或者插入时put等待队列出现空位。
/**
* 获取并移除队列头结点,如果必要,其会等待直到队列出现元素
*/
E take() throws InterruptedException;
/**
* 插入元素,如果队列已满,则等待直到队列出现空闲空间
*/
void put(E e) throws InterruptedException;
另一个BlockingQueue经常被考察的点就是是否有界Bounded、Unbounded这一点也往往会影响我们在应用开发中的选择我这里简单总结一下。
ArrayBlockingQueue是最典型的的有界队列其内部以final的数组保存数据数组的大小就决定了队列的边界所以我们在创建ArrayBlockingQueue时都要指定容量
public ArrayBlockingQueue(int capacity, boolean fair)
LinkedBlockingQueue容易被误解为无边界但其实其行为和内部代码都是基于有界的逻辑实现的只不过如果我们没有在创建队列时就指定容量那么其容量限制就自动被设置为Integer.MAX_VALUE成为了无界队列。
SynchronousQueue这是一个非常奇葩的队列实现每个删除操作都要等待插入操作反之每个插入操作也都要等待删除动作。那么这个队列的容量是多少呢是1吗其实不是的其内部容量是0。
PriorityBlockingQueue是无边界的优先队列虽然严格意义上来讲其大小总归是要受系统资源影响。
DelayedQueue和LinkedTransferQueue同样是无边界的队列。对于无边界的队列有一个自然的结果就是put操作永远也不会发生其他BlockingQueue的那种等待情况。
如果我们分析不同队列的底层实现BlockingQueue基本都是基于锁实现一起来看看典型的LinkedBlockingQueue。
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
我在介绍ReentrantLock的条件变量用法的时候分析过ArrayBlockingQueue不知道你有没有注意到其条件变量与LinkedBlockingQueue版本的实现是有区别的。notEmpty、notFull都是同一个再入锁的条件变量而LinkedBlockingQueue则改进了锁操作的粒度头、尾操作使用不同的锁所以在通用场景下它的吞吐量相对要更好一些。
下面的take方法与ArrayBlockingQueue中的实现也是有不同的由于其内部结构是链表需要自己维护元素数量值请参考下面的代码。
public E take() throws InterruptedException {
final E x;
final int c;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
notEmpty.await();
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
类似ConcurrentLinkedQueue等则是基于CAS的无锁技术不需要在每个操作时使用锁所以扩展性表现要更加优异。
相对比较另类的SynchronousQueue在Java 6中其实现发生了非常大的变化利用CAS替换掉了原本基于锁的逻辑同步开销比较小。它是Executors.newCachedThreadPool()的默认队列。
队列使用场景与典型用例
在实际开发中我提到过Queue被广泛使用在生产者-消费者场景比如利用BlockingQueue来实现由于其提供的等待机制我们可以少操心很多协调工作你可以参考下面样例代码
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class ConsumerProducer {
public static final String EXIT_MSG = "Good bye!";
public static void main(String[] args) {
// 使用较小的队列,以更好地在输出中展示其影响
BlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
Producer producer = new Producer(queue);
Consumer consumer = new Consumer(queue);
new Thread(producer).start();
new Thread(consumer).start();
}
static class Producer implements Runnable {
private BlockingQueue<String> queue;
public Producer(BlockingQueue<String> q) {
this.queue = q;
}
@Override
public void run() {
for (int i = 0; i < 20; i++) {
try{
Thread.sleep(5L);
String msg = "Message" + i;
System.out.println("Produced new item: " + msg);
queue.put(msg);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
System.out.println("Time to say good bye!");
queue.put(EXIT_MSG);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
static class Consumer implements Runnable{
private BlockingQueue<String> queue;
public Consumer(BlockingQueue<String> q){
this.queue=q;
}
@Override
public void run() {
try{
String msg;
while(!EXIT_MSG.equalsIgnoreCase( (msg = queue.take()))){
System.out.println("Consumed item: " + msg);
Thread.sleep(10L);
}
System.out.println("Got exit message, bye!");
}catch(InterruptedException e) {
e.printStackTrace();
}
}
}
}
上面是一个典型的生产者-消费者样例如果使用非Blocking的队列那么我们就要自己去实现轮询、条件判断如检查poll返回值是否null等逻辑如果没有特别的场景要求Blocking实现起来代码更加简单、直观。
前面介绍了各种队列实现,在日常的应用开发中,如何进行选择呢?
以LinkedBlockingQueue、ArrayBlockingQueue和SynchronousQueue为例我们一起来分析一下根据需求可以从很多方面考量
考虑应用场景中对队列边界的要求。ArrayBlockingQueue是有明确的容量限制的而LinkedBlockingQueue则取决于我们是否在创建时指定SynchronousQueue则干脆不能缓存任何元素。
从空间利用角度数组结构的ArrayBlockingQueue要比LinkedBlockingQueue紧凑因为其不需要创建所谓节点但是其初始分配阶段就需要一段连续的空间所以初始内存需求更大。
通用场景中LinkedBlockingQueue的吞吐量一般优于ArrayBlockingQueue因为它实现了更加细粒度的锁操作。
ArrayBlockingQueue实现比较简单性能更好预测属于表现稳定的“选手”。
如果我们需要实现的是两个线程之间接力性handoff的场景按照[专栏上一讲]的例子你可能会选择CountDownLatch但是SynchronousQueue也是完美符合这种场景的而且线程间协调和数据传输统一起来代码更加规范。
可能令人意外的是很多时候SynchronousQueue的性能表现往往大大超过其他实现尤其是在队列元素较小的场景。
今天我分析了Java中让人眼花缭乱的各种线程安全队列试图从几个角度让每个队列的特点更加明确进而希望减少你在日常工作中使用时的困扰。
一课一练
关于今天我们讨论的题目你做到心中有数了吗? 今天的内容侧重于Java自身的角度面试官也可能从算法的角度来考察所以今天留给你的思考题是指定某种结构比如栈用它实现一个BlockingQueue实现思路是怎样的呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,214 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 Java并发类库提供的线程池有哪几种 分别有什么特点?-极客时间
我在【专栏第 17 讲】中介绍过线程是不能够重复启动的,创建或销毁线程存在一定的开销,所以利用线程池技术来提高系统资源利用效率,并简化线程管理,已经是非常成熟的选择。
今天我要问你的问题是Java 并发类库提供的线程池有哪几种? 分别有什么特点?
典型回答
通常开发者都是利用 Executors 提供的通用线程池创建方法,去创建不同配置的线程池,主要区别在于不同的 ExecutorService 类型或者不同的初始参数。
Executors 目前提供了 5 种不同的线程池创建配置:
newCachedThreadPool(),它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过 60 秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用 SynchronousQueue 作为工作队列。
newFixedThreadPool(int nThreads)重用指定数目nThreads的线程其背后使用的是无界的工作队列任何时候最多有 nThreads 个工作线程是活动的。这意味着,如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目 nThreads。
newSingleThreadExecutor(),它的特点在于工作线程数目被限制为 1操作一个无界的工作队列所以它保证了所有任务的都是被顺序执行最多会有一个任务处于活动状态并且不允许使用者改动线程池实例因此可以避免其改变线程数目。
newSingleThreadScheduledExecutor() 和 newScheduledThreadPool(int corePoolSize),创建的是个 ScheduledExecutorService可以进行定时或周期性的工作调度区别在于单一工作线程还是多个工作线程。
newWorkStealingPool(int parallelism)这是一个经常被人忽略的线程池Java 8 才加入这个创建方法其内部会构建ForkJoinPool利用Work-Stealing算法并行地处理任务不保证处理顺序。
考点分析
Java 并发包中的 Executor 框架无疑是并发编程中的重点,今天的题目考察的是对几种标准线程池的了解,我提供的是一个针对最常见的应用方式的回答。
在大多数应用场景下,使用 Executors 提供的 5 个静态工厂方法就足够了,但是仍然可能需要直接利用 ThreadPoolExecutor 等构造函数创建,这就要求你对线程构造方式有进一步的了解,你需要明白线程池的设计和结构。
另外,线程池这个定义就是个容易让人误解的术语,因为 ExecutorService 除了通常意义上“池”的功能,还提供了更全面的线程管理、任务提交等方法。
Executor 框架可不仅仅是线程池,我觉得至少下面几点值得深入学习:
掌握 Executor 框架的主要内容,至少要了解组成与职责,掌握基本开发用例中的使用。
对线程池和相关并发工具类型的理解,甚至是源码层面的掌握。
实践中有哪些常见问题,基本的诊断思路是怎样的。
如何根据自身应用特点合理使用线程池。
知识扩展
首先,我们来看看 Executor 框架的基本组成,请参考下面的类图。
我们从整体上把握一下各个类型的主要设计目的:
Executor 是一个基础的接口,其初衷是将任务提交和任务执行细节解耦,这一点可以体会其定义的唯一方法。
void execute(Runnable command);
Executor 的设计是源于 Java 早期线程 API 使用的教训,开发者在实现应用逻辑时,被太多线程创建、调度等不相关细节所打扰。就像我们进行 HTTP 通信,如果还需要自己操作 TCP 握手,开发效率低下,质量也难以保证。
ExecutorService 则更加完善,不仅提供 service 的管理功能,比如 shutdown 等方法也提供了更加全面的提交任务机制如返回Future而不是 void 的 submit 方法。
<T> Future<T> submit(Callable<T> task);
注意这个例子输入的可是Callable它解决了 Runnable 无法返回结果的困扰。
Java 标准类库提供了几种基础实现比如ThreadPoolExecutor、ScheduledThreadPoolExecutor、ForkJoinPool。这些线程池的设计特点在于其高度的可调节性和灵活性以尽量满足复杂多变的实际应用场景我会进一步分析其构建部分的源码剖析这种灵活性的源头。
Executors 则从简化使用的角度,为我们提供了各种方便的静态工厂方法。
下面我就从源码角度,分析线程池的设计与实现,我将主要围绕最基础的 ThreadPoolExecutor 源码。ScheduledThreadPoolExecutor 是 ThreadPoolExecutor 的扩展,主要是增加了调度逻辑,如想深入了解,你可以参考相关教程。而 ForkJoinPool 则是为 ForkJoinTask 定制的线程池,与通常意义的线程池有所不同。
这部分内容比较晦涩,罗列概念也不利于你去理解,所以我会配合一些示意图来说明。在现实应用中,理解应用与线程池的交互和线程池的内部工作过程,你可以参考下图。
简单理解一下:
工作队列负责存储用户提交的各个任务,这个工作队列,可以是容量为 0 的 SynchronousQueue使用 newCachedThreadPool也可以是像固定大小线程池newFixedThreadPool那样使用 LinkedBlockingQueue。
private final BlockingQueue<Runnable> workQueue;
内部的“线程池”,这是指保持工作线程的集合,线程池需要在运行过程中管理线程创建、销毁。例如,对于带缓存的线程池,当任务压力较大时,线程池会创建新的工作线程;当业务压力退去,线程池会在闲置一段时间(默认 60 秒)后结束线程。
private final HashSet<Worker> workers = new HashSet<>();
线程池的工作线程被抽象为静态内部类 Worker基于AQS实现。
ThreadFactory 提供上面所需要的创建线程逻辑。
如果任务提交时被拒绝,比如线程池已经处于 SHUTDOWN 状态需要为其提供处理逻辑Java 标准库提供了类似ThreadPoolExecutor.AbortPolicy等默认实现也可以按照实际需求自定义。
从上面的分析,就可以看出线程池的几个基本组成部分,一起都体现在线程池的构造函数中,从字面我们就可以大概猜测到其用意:
corePoolSize所谓的核心线程数可以大致理解为长期驻留的线程数目除非设置了 allowCoreThreadTimeOut。对于不同的线程池这个值可能会有很大区别比如 newFixedThreadPool 会将其设置为 nThreads而对于 newCachedThreadPool 则是为 0。
maximumPoolSize顾名思义就是线程不够时能够创建的最大线程数。同样进行对比对于 newFixedThreadPool当然就是 nThreads因为其要求是固定大小而 newCachedThreadPool 则是 Integer.MAX_VALUE。
keepAliveTime 和 TimeUnit这两个参数指定了额外的线程能够闲置多久显然有些线程池不需要它。
workQueue工作队列必须是 BlockingQueue。
通过配置不同的参数,我们就可以创建出行为大相径庭的线程池,这就是线程池高度灵活性的基础。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
进一步分析,线程池既然有生命周期,它的状态是如何表征的呢?
这里有一个非常有意思的设计ctl 变量被赋予了双重角色,通过高低位的不同,既表示线程池状态,又表示工作线程数目,这是一个典型的高效优化。试想,实际系统中,虽然我们可以指定线程极限为 Integer.MAX_VALUE但是因为资源限制这只是个理论值所以完全可以将空闲位赋予其他意义。
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
// 真正决定了工作线程数的理论上限
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;
// 线程池状态存储在数字的高位
private static final int RUNNING = -1 << COUNT_BITS;
// Packing and unpacking ctl
private static int runStateOf(int c) { return c & ~COUNT_MASK; }
private static int workerCountOf(int c) { return c & COUNT_MASK; }
private static int ctlOf(int rs, int wc) { return rs | wc; }
为了让你能对线程生命周期有个更加清晰的印象我这里画了一个简单的状态流转图对线程池的可能状态和其内部方法之间进行了对应如果有不理解的方法请参考 Javadoc注意实际 Java 代码中并不存在所谓 Idle 状态我添加它仅仅是便于理解
前面都是对线程池属性和构建等方面的分析下面我选择典型的 execute 方法来看看其是如何工作的具体逻辑请参考我添加的注释配合代码更加容易理解
public void execute(Runnable command) {
int c = ctl.get();
// 检查工作线程数目低于corePoolSize则添加Worker
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// isRunning就是检查线程池是否被shutdown
// 工作队列可能是有界的offer是比较友好的入队方式
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 再次进行防御性检查
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 尝试添加一个worker如果失败意味着已经饱和或者被shutdown了
else if (!addWorker(command, false))
reject(command);
}
线程池实践
线程池虽然为提供了非常强大方便的功能但是也不是银弹使用不当同样会导致问题我这里介绍些典型情况经过前面的分析很多方面可以自然的推导出来
避免任务堆积前面我说过 newFixedThreadPool 是创建指定数目的线程但是其工作队列是无界的如果工作线程数目太少导致处理跟不上入队的速度这就很有可能占用大量系统内存甚至是出现 OOM诊断时你可以使用 jmap 之类的工具查看是否有大量的任务对象入队
避免过度扩展线程我们通常在处理大量短时任务时使用缓存的线程池比如在最新的 HTTP/2 client API 目前的默认实现就是如此我们在创建线程池的时候并不能准确预计任务压力有多大数据特征是什么样子大部分请求是 1K 100K 还是 1M 以上所以很难明确设定一个线程数目
另外如果线程数目不断增长可以使用 jstack 等工具检查也需要警惕另外一种可能性就是线程泄漏这种情况往往是因为任务逻辑有问题导致工作线程迟迟不能被释放建议你排查下线程栈很有可能多个线程都是卡在近似的代码处
避免死锁等同步问题对于死锁的场景和排查你可以复习专栏第 18
尽量避免在使用线程池时操作 ThreadLocal同样是专栏第 17 已经分析过的通过今天的线程池学习应该更能理解其原因工作线程的生命周期通常都会超过任务的生命周期
线程池大小的选择策略
上面我已经介绍过线程池大小不合适太多或太少都会导致麻烦所以我们需要去考虑一个合适的线程池大小虽然不能完全确定但是有一些相对普适的规则和思路
如果我们的任务主要是进行计算那么就意味着 CPU 的处理能力是稀缺的资源我们能够通过大量增加线程数提高计算能力吗往往是不能的如果线程太多反倒可能导致大量的上下文切换开销所以这种情况下通常建议按照 CPU 核的数目 N 或者 N+1
如果是需要较多等待的任务例如 I/O 操作比较多可以参考 Brain Goetz 推荐的计算方法
线程数 = CPU核数 × 目标CPU利用率 ×1 + 平均等待时间/平均工作时间
这些时间并不能精准预计需要根据采样或者概要分析等方式进行计算然后在实际中验证和调整
上面是仅仅考虑了 CPU 等限制实际还可能受各种系统资源限制影响例如我最近就在 Mac OS X 上遇到了大负载时ephemeral 端口受限的情况当然我是通过扩大可用端口范围解决的如果我们不能调整资源的容量那么就只能限制工作线程的数目了这里的资源可以是文件句柄内存等
另外在实际工作中不要把解决问题的思路全部指望到调整线程池上很多时候架构上的改变更能解决问题比如利用背压机制的Reactive Stream合理的拆分等
今天我从 Java 创建的几种线程池开始 Executor 框架的主要组成线程池结构与生命周期等方面进行了讲解和分析希望对你有所帮助
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是从逻辑上理解线程池创建和生命周期请谈一谈如果利用 newSingleThreadExecutor() 创建一个线程池corePoolSizemaxPoolSize 等都是什么数值ThreadFactory 可能在线程池生命周期中被使用多少次怎么验证自己的判断
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习奖励礼券欢迎你与我一起讨论
你的朋友是不是也在准备面试呢你可以请朋友读把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,231 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 Java并发类库提供的线程池有哪几种 分别有什么特点?
我在[专栏第17讲]中介绍过线程是不能够重复启动的,创建或销毁线程存在一定的开销,所以利用线程池技术来提高系统资源利用效率,并简化线程管理,已经是非常成熟的选择。
今天我要问你的问题是Java并发类库提供的线程池有哪几种 分别有什么特点?
典型回答
通常开发者都是利用Executors提供的通用线程池创建方法去创建不同配置的线程池主要区别在于不同的ExecutorService类型或者不同的初始参数。
Executors目前提供了5种不同的线程池创建配置
newCachedThreadPool()它是一种用来处理大量短时间工作任务的线程池具有几个鲜明特点它会试图缓存线程并重用当无缓存线程可用时就会创建新的工作线程如果线程闲置的时间超过60秒则被终止并移出缓存长时间闲置时这种线程池不会消耗什么资源。其内部使用SynchronousQueue作为工作队列。
newFixedThreadPool(int nThreads)重用指定数目nThreads的线程其背后使用的是无界的工作队列任何时候最多有nThreads个工作线程是活动的。这意味着如果任务数量超过了活动队列数目将在工作队列中等待空闲线程出现如果有工作线程退出将会有新的工作线程被创建以补足指定的数目nThreads。
newSingleThreadExecutor()它的特点在于工作线程数目被限制为1操作一个无界的工作队列所以它保证了所有任务的都是被顺序执行最多会有一个任务处于活动状态并且不允许使用者改动线程池实例因此可以避免其改变线程数目。
newSingleThreadScheduledExecutor()和newScheduledThreadPool(int corePoolSize)创建的是个ScheduledExecutorService可以进行定时或周期性的工作调度区别在于单一工作线程还是多个工作线程。
newWorkStealingPool(int parallelism)这是一个经常被人忽略的线程池Java 8才加入这个创建方法其内部会构建ForkJoinPool利用Work-Stealing算法并行地处理任务不保证处理顺序。
考点分析
Java并发包中的Executor框架无疑是并发编程中的重点今天的题目考察的是对几种标准线程池的了解我提供的是一个针对最常见的应用方式的回答。
在大多数应用场景下使用Executors提供的5个静态工厂方法就足够了但是仍然可能需要直接利用ThreadPoolExecutor等构造函数创建这就要求你对线程构造方式有进一步的了解你需要明白线程池的设计和结构。
另外线程池这个定义就是个容易让人误解的术语因为ExecutorService除了通常意义上“池”的功能还提供了更全面的线程管理、任务提交等方法。
Executor框架可不仅仅是线程池我觉得至少下面几点值得深入学习
掌握Executor框架的主要内容至少要了解组成与职责掌握基本开发用例中的使用。
对线程池和相关并发工具类型的理解,甚至是源码层面的掌握。
实践中有哪些常见问题,基本的诊断思路是怎样的。
如何根据自身应用特点合理使用线程池。
知识扩展
首先我们来看看Executor框架的基本组成请参考下面的类图。
我们从整体上把握一下各个类型的主要设计目的:
Executor是一个基础的接口其初衷是将任务提交和任务执行细节解耦这一点可以体会其定义的唯一方法。
void execute(Runnable command);
Executor的设计是源于Java早期线程API使用的教训开发者在实现应用逻辑时被太多线程创建、调度等不相关细节所打扰。就像我们进行HTTP通信如果还需要自己操作TCP握手开发效率低下质量也难以保证。
ExecutorService则更加完善不仅提供service的管理功能比如shutdown等方法也提供了更加全面的提交任务机制如返回Future而不是void的submit方法。
<T> Future<T> submit(Callable<T> task);
注意这个例子输入的可是Callable它解决了Runnable无法返回结果的困扰。
Java标准类库提供了几种基础实现比如ThreadPoolExecutor、ScheduledThreadPoolExecutor、ForkJoinPool。这些线程池的设计特点在于其高度的可调节性和灵活性以尽量满足复杂多变的实际应用场景我会进一步分析其构建部分的源码剖析这种灵活性的源头。
Executors则从简化使用的角度为我们提供了各种方便的静态工厂方法。
下面我就从源码角度分析线程池的设计与实现我将主要围绕最基础的ThreadPoolExecutor源码。ScheduledThreadPoolExecutor是ThreadPoolExecutor的扩展主要是增加了调度逻辑如想深入了解你可以参考相关教程。而ForkJoinPool则是为ForkJoinTask定制的线程池与通常意义的线程池有所不同。
这部分内容比较晦涩,罗列概念也不利于你去理解,所以我会配合一些示意图来说明。在现实应用中,理解应用与线程池的交互和线程池的内部工作过程,你可以参考下图。
简单理解一下:
工作队列负责存储用户提交的各个任务这个工作队列可以是容量为0的SynchronousQueue使用newCachedThreadPool也可以是像固定大小线程池newFixedThreadPool那样使用LinkedBlockingQueue。
private final BlockingQueue<Runnable> workQueue;
内部的“线程池”这是指保持工作线程的集合线程池需要在运行过程中管理线程创建、销毁。例如对于带缓存的线程池当任务压力较大时线程池会创建新的工作线程当业务压力退去线程池会在闲置一段时间默认60秒后结束线程。
private final HashSet<Worker> workers = new HashSet<>();
线程池的工作线程被抽象为静态内部类Worker基于AQS实现。
ThreadFactory提供上面所需要的创建线程逻辑。
如果任务提交时被拒绝比如线程池已经处于SHUTDOWN状态需要为其提供处理逻辑Java标准库提供了类似ThreadPoolExecutor.AbortPolicy等默认实现也可以按照实际需求自定义。
从上面的分析,就可以看出线程池的几个基本组成部分,一起都体现在线程池的构造函数中,从字面我们就可以大概猜测到其用意:
corePoolSize所谓的核心线程数可以大致理解为长期驻留的线程数目除非设置了allowCoreThreadTimeOut。对于不同的线程池这个值可能会有很大区别比如newFixedThreadPool会将其设置为nThreads而对于newCachedThreadPool则是为0。
maximumPoolSize顾名思义就是线程不够时能够创建的最大线程数。同样进行对比对于newFixedThreadPool当然就是nThreads因为其要求是固定大小而newCachedThreadPool则是Integer.MAX_VALUE。
keepAliveTime和TimeUnit这两个参数指定了额外的线程能够闲置多久显然有些线程池不需要它。
workQueue工作队列必须是BlockingQueue。
通过配置不同的参数,我们就可以创建出行为大相径庭的线程池,这就是线程池高度灵活性的基础。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
进一步分析,线程池既然有生命周期,它的状态是如何表征的呢?
这里有一个非常有意思的设计ctl变量被赋予了双重角色通过高低位的不同既表示线程池状态又表示工作线程数目这是一个典型的高效优化。试想实际系统中虽然我们可以指定线程极限为Integer.MAX_VALUE但是因为资源限制这只是个理论值所以完全可以将空闲位赋予其他意义。
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
// 真正决定了工作线程数的理论上限
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;
// 线程池状态存储在数字的高位
private static final int RUNNING = -1 << COUNT_BITS;
// Packing and unpacking ctl
private static int runStateOf(int c) { return c & ~COUNT_MASK; }
private static int workerCountOf(int c) { return c & COUNT_MASK; }
private static int ctlOf(int rs, int wc) { return rs | wc; }
为了让你能对线程生命周期有个更加清晰的印象我这里画了一个简单的状态流转图对线程池的可能状态和其内部方法之间进行了对应如果有不理解的方法请参考Javadoc注意实际Java代码中并不存在所谓Idle状态我添加它仅仅是便于理解
前面都是对线程池属性和构建等方面的分析下面我选择典型的execute方法来看看其是如何工作的具体逻辑请参考我添加的注释配合代码更加容易理解
public void execute(Runnable command) {
int c = ctl.get();
// 检查工作线程数目低于corePoolSize则添加Worker
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// isRunning就是检查线程池是否被shutdown
// 工作队列可能是有界的offer是比较友好的入队方式
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 再次进行防御性检查
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 尝试添加一个worker如果失败意味着已经饱和或者被shutdown了
else if (!addWorker(command, false))
reject(command);
}
线程池实践
线程池虽然为提供了非常强大方便的功能但是也不是银弹使用不当同样会导致问题我这里介绍些典型情况经过前面的分析很多方面可以自然的推导出来
避免任务堆积前面我说过newFixedThreadPool是创建指定数目的线程但是其工作队列是无界的如果工作线程数目太少导致处理跟不上入队的速度这就很有可能占用大量系统内存甚至是出现OOM诊断时你可以使用jmap之类的工具查看是否有大量的任务对象入队
避免过度扩展线程我们通常在处理大量短时任务时使用缓存的线程池比如在最新的HTTP/2 client API中目前的默认实现就是如此我们在创建线程池的时候并不能准确预计任务压力有多大数据特征是什么样子大部分请求是1K 100K还是1M以上所以很难明确设定一个线程数目
另外如果线程数目不断增长可以使用jstack等工具检查也需要警惕另外一种可能性就是线程泄漏这种情况往往是因为任务逻辑有问题导致工作线程迟迟不能被释放建议你排查下线程栈很有可能多个线程都是卡在近似的代码处
避免死锁等同步问题对于死锁的场景和排查你可以复习[专栏第18讲]
尽量避免在使用线程池时操作ThreadLocal同样是[专栏第17讲]已经分析过的通过今天的线程池学习应该更能理解其原因工作线程的生命周期通常都会超过任务的生命周期
线程池大小的选择策略
上面我已经介绍过线程池大小不合适太多或太少都会导致麻烦所以我们需要去考虑一个合适的线程池大小虽然不能完全确定但是有一些相对普适的规则和思路
如果我们的任务主要是进行计算那么就意味着CPU的处理能力是稀缺的资源我们能够通过大量增加线程数提高计算能力吗往往是不能的如果线程太多反倒可能导致大量的上下文切换开销所以这种情况下通常建议按照CPU核的数目N或者N+1
如果是需要较多等待的任务例如I/O操作比较多可以参考Brain Goetz推荐的计算方法
线程数 = CPU核数 × 目标CPU利用率 ×1 + 平均等待时间/平均工作时间
这些时间并不能精准预计需要根据采样或者概要分析等方式进行计算然后在实际中验证和调整
上面是仅仅考虑了CPU等限制实际还可能受各种系统资源限制影响例如我最近就在Mac OS X上遇到了大负载时ephemeral端口受限的情况当然我是通过扩大可用端口范围解决的如果我们不能调整资源的容量那么就只能限制工作线程的数目了这里的资源可以是文件句柄内存等
另外在实际工作中不要把解决问题的思路全部指望到调整线程池上很多时候架构上的改变更能解决问题比如利用背压机制的Reactive Stream合理的拆分等
今天我从Java创建的几种线程池开始对Executor框架的主要组成线程池结构与生命周期等方面进行了讲解和分析希望对你有所帮助
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是从逻辑上理解线程池创建和生命周期请谈一谈如果利用newSingleThreadExecutor()创建一个线程池corePoolSizemaxPoolSize等都是什么数值ThreadFactory可能在线程池生命周期中被使用多少次怎么验证自己的判断
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习奖励礼券欢迎你与我一起讨论
你的朋友是不是也在准备面试呢你可以请朋友读把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,248 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 AtomicInteger底层实现原理是什么如何在自己的产品代码中应用CAS操作-极客时间
在今天这一讲中,我来分析一下并发包内部的组成,一起来看看各种同步结构、线程池等,是基于什么原理来设计和实现的。
今天我要问你的问题是AtomicInteger 底层实现原理是什么?如何在自己的产品代码中应用 CAS 操作?
典型回答
AtomicIntger 是对 int 类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于 CAScompare-and-swap技术。
所谓 CAS表征的是一系列操作的集合获取当前数值进行一些运算利用 CAS 指令试图进行更新。如果当前数值未变,代表没有其他线程进行并发修改,则成功更新。否则,可能出现不同的选择,要么进行重试,要么就返回一个成功或者失败的结果。
从 AtomicInteger 的内部属性可以看出,它依赖于 Unsafe 提供的一些底层能力,进行底层操作;以 volatile 的 value 字段,记录数值,以保证可见性。
private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
private volatile int value;
具体的原子操作细节,可以参考任意一个原子更新方法,比如下面的 getAndIncrement。
Unsafe 会利用 value 字段的内存地址偏移,直接完成操作。
public final int getAndIncrement() {
return U.getAndAddInt(this, VALUE, 1);
}
因为 getAndIncrement 需要返归数值,所以需要添加失败重试逻辑。
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
而类似 compareAndSet 这种返回 boolean 类型的函数,因为其返回值表现的就是成功与否,所以不需要重试。
public final boolean compareAndSet(int expectedValue, int newValue)
CAS 是 Java 并发中所谓 lock-free 机制的基础。
考点分析
今天的问题有点偏向于 Java 并发机制的底层了,虽然我们在开发中未必会涉及 CAS 的实现层面,但是理解其机制,掌握如何在 Java 中运用该技术,还是十分有必要的,尤其是这也是个并发编程的面试热点。
有的同学反馈面试官会问 CAS 更加底层是如何实现的,这依赖于 CPU 提供的特定指令具体根据体系结构的不同还存在着明显区别。比如x86 CPU 提供 cmpxchg 指令而在精简指令集的体系架构中则通常是靠一对儿指令如“load and reserve”和“store conditional”实现的在大多数处理器上 CAS 都是个非常轻量级的操作,这也是其优势所在。
大部分情况下,掌握到这个程度也就够用了,我认为没有必要让每个 Java 工程师都去了解到指令级别,我们进行抽象、分工就是为了让不同层面的开发者在开发中,可以尽量屏蔽不相关的细节。
如果我作为面试官,很有可能深入考察这些方向:
在什么场景下,可以采用 CAS 技术,调用 Unsafe 毕竟不是大多数场景的最好选择有没有更加推荐的方式呢毕竟我们掌握一个技术cool 不是目的,更不是为了应付面试,我们还是希望能在实际产品中有价值。
对 ReentrantLock、CyclicBarrier 等并发结构底层的实现技术的理解。
知识扩展
关于 CAS 的使用,你可以设想这样一个场景:在数据库产品中,为保证索引的一致性,一个常见的选择是,保证只有一个线程能够排他性地修改一个索引分区,如何在数据库抽象层面实现呢?
可以考虑为索引分区对象添加一个逻辑上的锁,例如,以当前独占的线程 ID 作为锁的数值,然后通过原子操作设置 lock 数值,来实现加锁和释放锁,伪代码如下:
public class AtomicBTreePartition {
private volatile long lock;
public void acquireLock(){}
public void releaseeLock(){}
}
那么在 Java 代码中我们怎么实现锁操作呢Unsafe 似乎不是个好的选择,例如,我就注意到类似 Cassandra 等产品,因为 Java 9 中移除了 Unsafe.moniterEnter()/moniterExit(),导致无法平滑升级到新的 JDK 版本。目前 Java 提供了两种公共 API可以实现这种 CAS 操作,比如使用 java.util.concurrent.atomic.AtomicLongFieldUpdater它是基于反射机制创建我们需要保证类型和字段名称正确。
private static final AtomicLongFieldUpdater<AtomicBTreePartition> lockFieldUpdater =
AtomicLongFieldUpdater.newUpdater(AtomicBTreePartition.class, "lock");
private void acquireLock(){
long t = Thread.currentThread().getId();
while (!lockFieldUpdater.compareAndSet(this, 0L, t)){
// 等待一会儿,数据库操作可能比较慢
}
}
Atomic 包提供了最常用的原子性数据类型,甚至是引用、数组等相关原子类型和更新操作工具,是很多线程安全程序的首选。
我在专栏第七讲中曾介绍使用原子数据类型和 Atomic*FieldUpdater创建更加紧凑的计数器实现以替代 AtomicLong。优化永远是针对特定需求、特定目的我这里的侧重点是介绍可能的思路具体还是要看需求。如果仅仅创建一两个对象其实完全没有必要进行前面的优化但是如果对象成千上万或者更多就要考虑紧凑性的影响了。而 atomic 包提供的LongAdder在高度竞争环境下可能就是比 AtomicLong 更佳的选择,尽管它的本质是空间换时间。
回归正题,如果是 Java 9 以后,我们完全可以采用另外一种方式实现,也就是 Variable Handle API这是源自于JEP 193提供了各种粒度的原子或者有序性的操作等。我将前面的代码修改为如下实现
private static final VarHandle HANDLE = MethodHandles.lookup().findStaticVarHandle
(AtomicBTreePartition.class, "lock");
private void acquireLock(){
long t = Thread.currentThread().getId();
while (!HANDLE.compareAndSet(this, 0L, t)){
// 等待一会儿,数据库操作可能比较慢
}
}
过程非常直观,首先,获取相应的变量句柄,然后直接调用其提供的 CAS 方法。
一般来说,我们进行的类似 CAS 操作,可以并且推荐使用 Variable Handle API 去实现,其提供了精细粒度的公共底层 API。我这里强调公共是因为其 API 不会像内部 API 那样,发生不可预测的修改,这一点提供了对于未来产品维护和升级的基础保障,坦白说,很多额外工作量,都是源于我们使用了 Hack 而非 Solution 的方式解决问题。
CAS 也并不是没有副作用,试想,其常用的失败重试机制,隐含着一个假设,即竞争情况是短暂的。大多数应用场景中,确实大部分重试只会发生一次就获得了成功,但是总是有意外情况,所以在有需要的时候,还是要考虑限制自旋的次数,以免过度消耗 CPU。
另外一个就是著名的ABA问题这是通常只在 lock-free 算法下暴露的问题。我前面说过 CAS 是在更新时比较前值,如果对方只是恰好相同,例如期间发生了 A -> B -> A 的更新,仅仅判断数值是 A可能导致不合理的修改操作。针对这种情况Java 提供了 AtomicStampedReference 工具类通过为引用建立类似版本号stamp的方式来保证 CAS 的正确性,具体用法请参考这里的介绍。
前面介绍了 CAS 的场景与实现幸运的是大多数情况下Java 开发者并不需要直接利用 CAS 代码去实现线程安全容器等,更多是通过并发包等间接享受到 lock-free 机制在扩展性上的好处。
下面我来介绍一下 AbstractQueuedSynchronizerAQS其是 Java 并发包中,实现各种同步结构和部分其他组成单元(如线程池中的 Worker的基础。
学习 AQS如果上来就去看它的一系列方法下图所示很有可能把自己看晕这种似懂非懂的状态也没有太大的实践意义。
我建议的思路是,尽量简化一下,理解为什么需要 AQS如何使用 AQS至少要做什么再进一步结合 JDK 源代码中的实践,理解 AQS 的原理与应用。
Doug Lea曾经介绍过 AQS 的设计初衷。从原理上,一种同步结构往往是可以利用其他的结构实现的,例如我在专栏第 19 讲中提到过可以使用 Semaphore 实现互斥锁。但是,对某种同步结构的倾向,会导致复杂、晦涩的实现逻辑,所以,他选择了将基础的同步相关操作抽象在 AbstractQueuedSynchronizer 中,利用 AQS 为我们构建同步结构提供了范本。
AQS 内部数据和方法,可以简单拆分为:
一个 volatile 的整数成员表征状态,同时提供了 setState 和 getState 方法
private volatile int state;
一个先入先出FIFO的等待线程队列以实现多线程间竞争和等待这是 AQS 机制的核心之一。
各种基于 CAS 的基础操作方法,以及各种期望具体同步结构去实现的 acquire/release 方法。
利用 AQS 实现一个同步结构,至少要实现两个基本类型的方法,分别是 acquire 操作,获取资源的独占权;还有就是 release 操作,释放对某个资源的独占。
以 ReentrantLock 为例,它内部通过扩展 AQS 实现了 Sync 类型,以 AQS 的 state 来反映锁的持有情况。
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer { …}
下面是 ReentrantLock 对应 acquire 和 release 操作,如果是 CountDownLatch 则可以看作是 await()/countDown(),具体实现也有区别。
public void lock() {
sync.acquire(1);
}
public void unlock() {
sync.release(1);
}
排除掉一些细节,整体地分析 acquire 方法逻辑,其直接实现是在 AQS 内部,调用了 tryAcquire 和 acquireQueued这是两个需要搞清楚的基本部分。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
首先,我们来看看 tryAcquire。在 ReentrantLock 中tryAcquire 逻辑实现在 NonfairSync 和 FairSync 中,分别提供了进一步的非公平或公平性方法,而 AQS 内部 tryAcquire 仅仅是个接近未实现的方法(直接抛异常),这是留个实现者自己定义的操作。
我们可以看到公平性在 ReentrantLock 构建时如何指定的,具体如下:
public ReentrantLock() {
sync = new NonfairSync(); // 默认是非公平的
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
以非公平的 tryAcquire 为例,其内部实现了如何配合状态与 CAS 获取锁,注意,对比公平版本的 tryAcquire它在锁无人占有时并不检查是否有其他等待者这里体现了非公平的语义。
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();// 获取当前AQS内部状态量
if (c == 0) { // 0表示无人占有则直接用CAS修改状态位
if (compareAndSetState(0, acquires)) {// 不检查排队情况,直接争抢
setExclusiveOwnerThread(current); //并设置当前线程独占锁
return true;
}
} else if (current == getExclusiveOwnerThread()) { //即使状态不是0也可能当前线程是锁持有者因为这是再入锁
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
接下来我再来分析 acquireQueued如果前面的 tryAcquire 失败代表着锁争抢失败进入排队竞争阶段这里就是我们所说的利用 FIFO 队列实现线程间对锁的竞争的部分算是是 AQS 的核心逻辑
当前线程会被包装成为一个排他模式的节点EXCLUSIVE通过 addWaiter 方法添加到队列中acquireQueued 的逻辑简要来说就是如果当前节点的前面是头节点则试图获取锁一切顺利则成为新的头节点否则有必要则等待具体处理逻辑请参考我添加的注释
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
for (;;) {// 循环
final Node p = node.predecessor();// 获取前一个节点
if (p == head && tryAcquire(arg)) { // 如果前一个节点是头结点表示当前节点合适去tryAcquire
setHead(node); // acquire成功则设置新的头节点
p.next = null; // 将前面节点对当前节点的引用清空
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node)) // 检查是否失败后需要park
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
cancelAcquire(node);// 出现异常取消
if (interrupted)
selfInterrupt();
throw t;
}
}
到这里线程试图获取锁的过程基本展现出来了tryAcquire 是按照特定场景需要开发者去实现的部分而线程间竞争则是 AQS 通过 Waiter 队列与 acquireQueued 提供的 release 方法中同样会对队列进行对应操作
今天我介绍了 Atomic 数据类型的底层技术 CAS并通过实例演示了如何在产品代码中利用 CAS最后介绍了并发包的基础技术 AQS希望对你有所帮助
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天布置一个源码阅读作业AQS Node waitStatus 有什么作用
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习奖励礼券欢迎你与我一起讨论
你的朋友是不是也在准备面试呢你可以请朋友读把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,244 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 AtomicInteger底层实现原理是什么如何在自己的产品代码中应用CAS操作
在今天这一讲中,我来分析一下并发包内部的组成,一起来看看各种同步结构、线程池等,是基于什么原理来设计和实现的。
今天我要问你的问题是AtomicInteger底层实现原理是什么如何在自己的产品代码中应用CAS操作
典型回答
AtomicIntger是对int类型的一个封装提供原子性的访问和更新操作其原子性操作的实现是基于CAScompare-and-swap技术。
所谓CAS表征的是一系列操作的集合获取当前数值进行一些运算利用CAS指令试图进行更新。如果当前数值未变代表没有其他线程进行并发修改则成功更新。否则可能出现不同的选择要么进行重试要么就返回一个成功或者失败的结果。
从AtomicInteger的内部属性可以看出它依赖于Unsafe提供的一些底层能力进行底层操作以volatile的value字段记录数值以保证可见性。
private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
private volatile int value;
具体的原子操作细节可以参考任意一个原子更新方法比如下面的getAndIncrement。
Unsafe会利用value字段的内存地址偏移直接完成操作。
public final int getAndIncrement() {
return U.getAndAddInt(this, VALUE, 1);
}
因为getAndIncrement需要返归数值所以需要添加失败重试逻辑。
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
而类似compareAndSet这种返回boolean类型的函数因为其返回值表现的就是成功与否所以不需要重试。
public final boolean compareAndSet(int expectedValue, int newValue)
CAS是Java并发中所谓lock-free机制的基础。
考点分析
今天的问题有点偏向于Java并发机制的底层了虽然我们在开发中未必会涉及CAS的实现层面但是理解其机制掌握如何在Java中运用该技术还是十分有必要的尤其是这也是个并发编程的面试热点。
有的同学反馈面试官会问CAS更加底层是如何实现的这依赖于CPU提供的特定指令具体根据体系结构的不同还存在着明显区别。比如x86 CPU提供cmpxchg指令而在精简指令集的体系架构中则通常是靠一对儿指令如“load and reserve”和“store conditional”实现的在大多数处理器上CAS都是个非常轻量级的操作这也是其优势所在。
大部分情况下掌握到这个程度也就够用了我认为没有必要让每个Java工程师都去了解到指令级别我们进行抽象、分工就是为了让不同层面的开发者在开发中可以尽量屏蔽不相关的细节。
如果我作为面试官,很有可能深入考察这些方向:
在什么场景下可以采用CAS技术调用Unsafe毕竟不是大多数场景的最好选择有没有更加推荐的方式呢毕竟我们掌握一个技术cool不是目的更不是为了应付面试我们还是希望能在实际产品中有价值。
对ReentrantLock、CyclicBarrier等并发结构底层的实现技术的理解。
知识扩展
关于CAS的使用你可以设想这样一个场景在数据库产品中为保证索引的一致性一个常见的选择是保证只有一个线程能够排他性地修改一个索引分区如何在数据库抽象层面实现呢
可以考虑为索引分区对象添加一个逻辑上的锁例如以当前独占的线程ID作为锁的数值然后通过原子操作设置lock数值来实现加锁和释放锁伪代码如下
public class AtomicBTreePartition {
private volatile long lock;
public void acquireLock(){}
public void releaseeLock(){}
}
那么在Java代码中我们怎么实现锁操作呢Unsafe似乎不是个好的选择例如我就注意到类似Cassandra等产品因为Java 9中移除了Unsafe.moniterEnter()/moniterExit()导致无法平滑升级到新的JDK版本。目前Java提供了两种公共API可以实现这种CAS操作比如使用java.util.concurrent.atomic.AtomicLongFieldUpdater它是基于反射机制创建我们需要保证类型和字段名称正确。
private static final AtomicLongFieldUpdater<AtomicBTreePartition> lockFieldUpdater =
AtomicLongFieldUpdater.newUpdater(AtomicBTreePartition.class, "lock");
private void acquireLock(){
long t = Thread.currentThread().getId();
while (!lockFieldUpdater.compareAndSet(this, 0L, t)){
// 等待一会儿,数据库操作可能比较慢
}
}
Atomic包提供了最常用的原子性数据类型甚至是引用、数组等相关原子类型和更新操作工具是很多线程安全程序的首选。
我在专栏第七讲中曾介绍使用原子数据类型和Atomic*FieldUpdater创建更加紧凑的计数器实现以替代AtomicLong。优化永远是针对特定需求、特定目的我这里的侧重点是介绍可能的思路具体还是要看需求。如果仅仅创建一两个对象其实完全没有必要进行前面的优化但是如果对象成千上万或者更多就要考虑紧凑性的影响了。而atomic包提供的LongAdder在高度竞争环境下可能就是比AtomicLong更佳的选择尽管它的本质是空间换时间。
回归正题如果是Java 9以后我们完全可以采用另外一种方式实现也就是Variable Handle API这是源自于JEP 193提供了各种粒度的原子或者有序性的操作等。我将前面的代码修改为如下实现
private static final VarHandle HANDLE = MethodHandles.lookup().findStaticVarHandle
(AtomicBTreePartition.class, "lock");
private void acquireLock(){
long t = Thread.currentThread().getId();
while (!HANDLE.compareAndSet(this, 0L, t)){
// 等待一会儿,数据库操作可能比较慢
}
}
过程非常直观首先获取相应的变量句柄然后直接调用其提供的CAS方法。
一般来说我们进行的类似CAS操作可以并且推荐使用Variable Handle API去实现其提供了精细粒度的公共底层API。我这里强调公共是因为其API不会像内部API那样发生不可预测的修改这一点提供了对于未来产品维护和升级的基础保障坦白说很多额外工作量都是源于我们使用了Hack而非Solution的方式解决问题。
CAS也并不是没有副作用试想其常用的失败重试机制隐含着一个假设即竞争情况是短暂的。大多数应用场景中确实大部分重试只会发生一次就获得了成功但是总是有意外情况所以在有需要的时候还是要考虑限制自旋的次数以免过度消耗CPU。
另外一个就是著名的ABA问题这是通常只在lock-free算法下暴露的问题。我前面说过CAS是在更新时比较前值如果对方只是恰好相同例如期间发生了 A -> B -> A的更新仅仅判断数值是A可能导致不合理的修改操作。针对这种情况Java提供了AtomicStampedReference工具类通过为引用建立类似版本号stamp的方式来保证CAS的正确性具体用法请参考这里的介绍。
前面介绍了CAS的场景与实现幸运的是大多数情况下Java开发者并不需要直接利用CAS代码去实现线程安全容器等更多是通过并发包等间接享受到lock-free机制在扩展性上的好处。
下面我来介绍一下AbstractQueuedSynchronizerAQS其是Java并发包中实现各种同步结构和部分其他组成单元如线程池中的Worker的基础。
学习AQS如果上来就去看它的一系列方法下图所示很有可能把自己看晕这种似懂非懂的状态也没有太大的实践意义。
我建议的思路是尽量简化一下理解为什么需要AQS如何使用AQS至少要做什么再进一步结合JDK源代码中的实践理解AQS的原理与应用。
Doug Lea曾经介绍过AQS的设计初衷。从原理上一种同步结构往往是可以利用其他的结构实现的例如我在专栏第19讲中提到过可以使用Semaphore实现互斥锁。但是对某种同步结构的倾向会导致复杂、晦涩的实现逻辑所以他选择了将基础的同步相关操作抽象在AbstractQueuedSynchronizer中利用AQS为我们构建同步结构提供了范本。
AQS内部数据和方法可以简单拆分为
一个volatile的整数成员表征状态同时提供了setState和getState方法
private volatile int state;
一个先入先出FIFO的等待线程队列以实现多线程间竞争和等待这是AQS机制的核心之一。
各种基于CAS的基础操作方法以及各种期望具体同步结构去实现的acquire/release方法。
利用AQS实现一个同步结构至少要实现两个基本类型的方法分别是acquire操作获取资源的独占权还有就是release操作释放对某个资源的独占。
以ReentrantLock为例它内部通过扩展AQS实现了Sync类型以AQS的state来反映锁的持有情况。
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer { …}
下面是ReentrantLock对应acquire和release操作如果是CountDownLatch则可以看作是await()/countDown(),具体实现也有区别。
public void lock() {
sync.acquire(1);
}
public void unlock() {
sync.release(1);
}
排除掉一些细节整体地分析acquire方法逻辑其直接实现是在AQS内部调用了tryAcquire和acquireQueued这是两个需要搞清楚的基本部分。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
首先我们来看看tryAcquire。在ReentrantLock中tryAcquire逻辑实现在NonfairSync和FairSync中分别提供了进一步的非公平或公平性方法而AQS内部tryAcquire仅仅是个接近未实现的方法直接抛异常这是留个实现者自己定义的操作。
我们可以看到公平性在ReentrantLock构建时如何指定的具体如下
public ReentrantLock() {
sync = new NonfairSync(); // 默认是非公平的
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
以非公平的tryAcquire为例其内部实现了如何配合状态与CAS获取锁注意对比公平版本的tryAcquire它在锁无人占有时并不检查是否有其他等待者这里体现了非公平的语义。
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();// 获取当前AQS内部状态量
if (c == 0) { // 0表示无人占有则直接用CAS修改状态位
if (compareAndSetState(0, acquires)) {// 不检查排队情况,直接争抢
setExclusiveOwnerThread(current); //并设置当前线程独占锁
return true;
}
} else if (current == getExclusiveOwnerThread()) { //即使状态不是0也可能当前线程是锁持有者因为这是再入锁
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
接下来我再来分析acquireQueued如果前面的tryAcquire失败代表着锁争抢失败进入排队竞争阶段这里就是我们所说的利用FIFO队列实现线程间对锁的竞争的部分算是是AQS的核心逻辑
当前线程会被包装成为一个排他模式的节点EXCLUSIVE通过addWaiter方法添加到队列中acquireQueued的逻辑简要来说就是如果当前节点的前面是头节点则试图获取锁一切顺利则成为新的头节点否则有必要则等待具体处理逻辑请参考我添加的注释
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
for (;;) {// 循环
final Node p = node.predecessor();// 获取前一个节点
if (p == head && tryAcquire(arg)) { // 如果前一个节点是头结点表示当前节点合适去tryAcquire
setHead(node); // acquire成功则设置新的头节点
p.next = null; // 将前面节点对当前节点的引用清空
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node)) // 检查是否失败后需要park
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
cancelAcquire(node);// 出现异常取消
if (interrupted)
selfInterrupt();
throw t;
}
}
到这里线程试图获取锁的过程基本展现出来了tryAcquire是按照特定场景需要开发者去实现的部分而线程间竞争则是AQS通过Waiter队列与acquireQueued提供的在release方法中同样会对队列进行对应操作
今天我介绍了Atomic数据类型的底层技术CAS并通过实例演示了如何在产品代码中利用CAS最后介绍了并发包的基础技术AQS希望对你有所帮助
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天布置一个源码阅读作业AQS中Node的waitStatus有什么作用
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习奖励礼券欢迎你与我一起讨论
你的朋友是不是也在准备面试呢你可以请朋友读把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,218 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 请介绍类加载过程,什么是双亲委派模型?-极客时间
Java 通过引入字节码和 JVM 机制,提供了强大的跨平台能力,理解 Java 的类加载机制是深入 Java 开发的必要条件,也是个面试考察热点。
今天我要问你的问题是,请介绍类加载过程,什么是双亲委派模型?
典型回答
一般来说,我们把 Java 的类加载过程分为三个主要步骤加载、链接、初始化具体行为在Java 虚拟机规范里有非常详细的定义。
首先是加载阶段Loading它是 Java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构Class 对象),这里的数据源可能是各种各样的形态,如 jar 文件、class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。
加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。
第二阶段是链接Linking这是核心的步骤简单说是把原始的类定义信息平滑地转化入 JVM 运行的过程中。这里可进一步细分为三个步骤:
验证Verification这是虚拟机安全的重要保障JVM 需要核验字节信息是符合 Java 虚拟机规范的,否则就被认为是 VerifyError这样就防止了恶意信息或者不合规的信息危害 JVM 的运行,验证阶段有可能触发更多 class 的加载。
准备Preparation创建类或接口中的静态变量并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的侧重点在于分配所需要的内存空间不会去执行更进一步的 JVM 指令。
解析Resolution在这一步会将常量池中的符号引用symbolic reference替换为直接引用。在Java 虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析。
最后是初始化阶段initialization这一步真正去执行类初始化的代码逻辑包括静态字段赋值的动作以及执行类定义中的静态初始化块内的逻辑编译器在编译阶段就会把这部分逻辑整理好父类型的初始化逻辑优先于当前类型的逻辑。
再来谈谈双亲委派模型简单说就是当类加载器Class-Loader试图加载某个类型的时候除非父加载器找不到相应类型否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型。
考点分析
今天的问题是关于 JVM 类加载方面的基础问题,我前面给出的回答参考了 Java 虚拟机规范中的主要条款。如果你在面试中回答这个问题,在这个基础上还可以举例说明。
我们来看一个经典的延伸问题,准备阶段谈到静态变量,那么对于常量和不同静态变量有什么区别?
需要明确的是,没有人能够精确的理解和记忆所有信息,如果碰到这种问题,有直接答案当然最好;没有的话,就说说自己的思路。
我们定义下面这样的类型,分别提供了普通静态变量、静态常量,常量又考虑到原始类型和引用类型可能有区别。
public class CLPreparation {
public static int a = 100;
public static final int INT_CONSTANT = 1000;
public static final Integer INTEGER_CONSTANT = Integer.valueOf(10000);
}
编译并反编译一下:
Javac CLPreparation.java
Javap v CLPreparation.class
可以在字节码中看到这样的额外初始化逻辑:
0: bipush 100
2: putstatic #2 // Field a:I
5: sipush 10000
8: invokestatic #3 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
11: putstatic #4 // Field INTEGER_CONSTANT:Ljava/lang/Integer;
这能让我们更清楚,普通原始类型静态变量和引用类型(即使是常量),是需要额外调用 putstatic 等 JVM 指令的,这些是在显式初始化阶段执行,而不是准备阶段调用;而原始类型常量,则不需要这样的步骤。
关于类加载过程的更多细节,有非常多的优秀资料进行介绍,你可以参考大名鼎鼎的《深入理解 Java 虚拟机》,一本非常好的入门书籍。我的建议是不要仅看教程,最好能够想出代码实例去验证自己对某个方面的理解和判断,这样不仅能加深理解,还能够在未来的应用开发中使用到。
其实,类加载机制的范围实在太大,我从开发和部署的不同角度,各选取了一个典型扩展问题供你参考:
如果要真正理解双亲委派模型,需要理解 Java 中类加载器的架构和职责,至少要懂具体有哪些内建的类加载器,这些是我上面的回答里没有提到的;以及如何自定义类加载器?
从应用角度,解决某些类加载问题,例如我的 Java 程序启动较慢,有没有办法尽量减小 Java 类加载的开销?
另外,需要注意的是,在 Java 9 中Jigsaw 项目为 Java 提供了原生的模块化支持,内建的类加载器结构和机制发生了明显变化。我会对此进行讲解,希望能够避免一些未来升级中可能发生的问题。
知识扩展
首先,从架构角度,一起来看看 Java 8 以前各种类加载器的结构,下面是三种 Oracle JDK 内建的类加载器。
启动类加载器Bootstrap Class-Loader加载 jre/lib 下面的 jar 文件,如 rt.jar。它是个超级公民即使是在开启了 Security Manager 的时候JDK 仍赋予了它加载的程序 AllPermission。
对于做底层开发的工程师,有的时候可能不得不去试图修改 JDK 的基础代码,也就是通常意义上的核心类库,我们可以使用下面的命令行参数。
# 指定新的bootclasspath替换java.*包的内部实现
java -Xbootclasspath:<your_boot_classpath> your_App
# a意味着append将指定目录添加到bootclasspath后面
java -Xbootclasspath/a:<your_dir> your_App
# p意味着prepend将指定目录添加到bootclasspath前面
java -Xbootclasspath/p:<your_dir> your_App
用法其实很易懂,例如,使用最常见的 “/p”既然是前置就有机会替换个别基础类的实现。
我们一般可以使用下面方法获取父加载器,但是在通常的 JDK/JRE 实现中,扩展类加载器 getParent() 都只能返回 null。
public final ClassLoader getParent()
扩展类加载器Extension or Ext Class-Loader负责加载我们放到 jre/lib/ext/ 目录下面的 jar 包,这就是所谓的 extension 机制。该目录也可以通过设置 “java.ext.dirs”来覆盖。
java -Djava.ext.dirs=your_ext_dir HelloWorld
应用类加载器Application or App Class-Loader就是加载我们最熟悉的 classpath 的内容。这里有一个容易混淆的概念系统System类加载器通常来说其默认就是 JDK 内建的应用类加载器,但是它同样是可能修改的,比如:
java -Djava.system.class.loader=com.yourcorp.YourClassLoader HelloWorld
如果我们指定了这个参数JDK 内建的应用类加载器就会成为定制加载器的父亲,这种方式通常用在类似需要改变双亲委派模式的场景。
具体请参考下图:
至于前面被问到的双亲委派模型,参考这个结构图更容易理解。试想,如果不同类加载器都自己加载需要的某个类型,那么就会出现多次重复加载,完全是种浪费。
通常类加载机制有三个基本特征:
双亲委派模型。但不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如 JDK 内部的 ServiceProvider/ServiceLoader机制用户可以在标准 API 框架上提供自己的实现JDK 也需要提供些默认的参考实现。 例如Java 中 JNDI、JDBC、文件系统、Cipher 等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。
可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的,不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见。
在 JDK 9 中,由于 Jigsaw 项目引入了 Java 平台模块化系统JPMSJava SE 的源代码被划分为一系列模块。
类加载器,类文件容器等都发生了非常大的变化,我这里总结一下:
前面提到的 -Xbootclasspath 参数不可用了。API 已经被划分到具体的模块,所以上文中,利用“-Xbootclasspath/p”替换某个 Java 核心类型代码,实际上变成了对相应的模块进行的修补,可以采用下面的解决方案:
首先,确认要修改的类文件已经编译好,并按照对应模块(假设是 java.base结构存放 然后,给模块打补丁:
java --patch-module java.base=your_patch yourApp
扩展类加载器被重命名为平台类加载器Platform Class-Loader而且 extension 机制则被移除。也就意味着,如果我们指定 java.ext.dirs 环境变量,或者 lib/ext 目录存在JVM 将直接返回错误!建议解决办法就是将其放入 classpath 里。
部分不需要 AllPermission 的 Java 基础模块,被降级到平台类加载器中,相应的权限也被更精细粒度地限制起来。
rt.jar 和 tools.jar 同样是被移除了JDK 的核心类库以及相关资源,被存储在 jimage 文件中,并通过新的 JRT 文件系统访问,而不是原有的 JAR 文件系统。虽然看起来很惊人,但幸好对于大部分软件的兼容性影响,其实是有限的,更直接地影响是 IDE 等软件,通常只要升级到新版本就可以了。
增加了 Layer 的抽象, JVM 启动默认创建 BootLayer开发者也可以自己去定义和实例化 Layer可以更加方便的实现类似容器一般的逻辑抽象。
结合了 Layer目前的 JVM 内部结构就变成了下面的层次,内建类加载器都在 BootLayer 中,其他 Layer 内部有自定义的类加载器,不同版本模块可以同时工作在不同的 Layer。
谈到类加载器,绕不过的一个话题是自定义类加载器,常见的场景有:
实现类似进程内隔离类加载器实际上用作不同的命名空间以提供类似容器、模块化的效果。例如两个模块依赖于某个类库的不同版本如果分别被不同的容器加载就可以互不干扰。这个方面的集大成者是Java EE和OSGI、JPMS等框架。
应用需要从不同的数据源获取类定义信息,例如网络数据源,而不是本地文件系统。
或者是需要自己操纵字节码,动态修改或者生成类型。
我们可以总体上简单理解自定义类加载过程:
通过指定名称,找到其二进制实现,这里往往就是自定义类加载器会“定制”的部分,例如,在特定数据源根据名字获取字节码,或者修改或生成字节码。
然后,创建 Class 对象,并完成类加载过程。二进制信息到 Class 对象的转换通常就依赖defineClass我们无需自己实现它是 final 方法。有了 Class 对象,后续完成加载过程就顺理成章了。
具体实现我建议参考这个用例。
我在【专栏第 1 讲】中,就提到了由于字节码是平台无关抽象,而不是机器码,所以 Java 需要类加载和解释、编译,这些都导致 Java 启动变慢。谈了这么多类加载,有没有什么通用办法,不需要代码和其他工作量,就可以降低类加载的开销呢?
这个,可以有。
在第 1 讲中提到的 AOT相当于直接编译成机器码降低的其实主要是解释和编译开销。但是其目前还是个试验特性支持的平台也有限比如JDK 9 仅支持 Linux x64所以局限性太大先暂且不谈。
还有就是较少人知道的 AppCDSApplication Class-Data SharingCDS 在 Java 5 中被引进,但仅限于 Bootstrap Class-loader在 8u40 中实现了 AppCDS支持其他的类加载器在目前 2018 年初发布的 JDK 10 中已经开源。
简单来说AppCDS 基本原理和工作过程是:
首先JVM 将类信息加载, 解析成为元数据,并根据是否需要修改,将其分类为 Read-Only 部分和 Read-Write 部分。然后,将这些元数据直接存储在文件系统中,作为所谓的 Shared Archive。命令很简单
Java -Xshare:dump -XX:+UseAppCDS -XX:SharedArchiveFile=<jsa> \
-XX:SharedClassListFile=<classlist> -XX:SharedArchiveConfigFile=<config_file>
第二,在应用程序启动时,指定归档文件,并开启 AppCDS。
Java -Xshare:on -XX:+UseAppCDS -XX:SharedArchiveFile=<jsa> yourApp
通过上面的命令JVM 会通过内存映射技术,直接映射到相应的地址空间,免除了类加载、解析等各种开销。
AppCDS 改善启动速度非常明显,传统的 Java EE 应用,一般可以提高 20%~30% 以上;实验中使用 Spark KMeans 负载20 个 slave可以提高 11% 的启动速度。
与此同时,降低内存 footprint因为同一环境的 Java 进程间可以共享部分数据结构。前面谈到的两个实验,平均可以减少 10% 以上的内存消耗。
当然,也不是没有局限性,如果恰好大量使用了运行时动态类加载,它的帮助就有限了。
今天我梳理了一下类加载的过程,并针对 Java 新版中类加载机制发生的变化,进行了相对全面的总结,最后介绍了一个改善类加载速度的特性,希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,谈谈什么是 Jar Hell 问题?你有遇到过类似情况吗,如何解决呢?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,226 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 请介绍类加载过程,什么是双亲委派模型?
Java通过引入字节码和JVM机制提供了强大的跨平台能力理解Java的类加载机制是深入Java开发的必要条件也是个面试考察热点。
今天我要问你的问题是,请介绍类加载过程,什么是双亲委派模型?
典型回答
一般来说我们把Java的类加载过程分为三个主要步骤加载、链接、初始化具体行为在Java虚拟机规范里有非常详细的定义。
首先是加载阶段Loading它是Java将字节码数据从不同的数据源读取到JVM中并映射为JVM认可的数据结构Class对象这里的数据源可能是各种各样的形态如jar文件、class文件甚至是网络数据源等如果输入数据不是ClassFile的结构则会抛出ClassFormatError。
加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。
第二阶段是链接Linking这是核心的步骤简单说是把原始的类定义信息平滑地转化入JVM运行的过程中。这里可进一步细分为三个步骤
验证Verification这是虚拟机安全的重要保障JVM需要核验字节信息是符合Java虚拟机规范的否则就被认为是VerifyError这样就防止了恶意信息或者不合规的信息危害JVM的运行验证阶段有可能触发更多class的加载。
准备Preparation创建类或接口中的静态变量并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的侧重点在于分配所需要的内存空间不会去执行更进一步的JVM指令。
解析Resolution在这一步会将常量池中的符号引用symbolic reference替换为直接引用。在Java虚拟机规范中详细介绍了类、接口、方法和字段等各个方面的解析。
最后是初始化阶段initialization这一步真正去执行类初始化的代码逻辑包括静态字段赋值的动作以及执行类定义中的静态初始化块内的逻辑编译器在编译阶段就会把这部分逻辑整理好父类型的初始化逻辑优先于当前类型的逻辑。
再来谈谈双亲委派模型简单说就是当类加载器Class-Loader试图加载某个类型的时候除非父加载器找不到相应类型否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载Java类型。
考点分析
今天的问题是关于JVM类加载方面的基础问题我前面给出的回答参考了Java虚拟机规范中的主要条款。如果你在面试中回答这个问题在这个基础上还可以举例说明。
我们来看一个经典的延伸问题,准备阶段谈到静态变量,那么对于常量和不同静态变量有什么区别?
需要明确的是,没有人能够精确的理解和记忆所有信息,如果碰到这种问题,有直接答案当然最好;没有的话,就说说自己的思路。
我们定义下面这样的类型,分别提供了普通静态变量、静态常量,常量又考虑到原始类型和引用类型可能有区别。
public class CLPreparation {
public static int a = 100;
public static final int INT_CONSTANT = 1000;
public static final Integer INTEGER_CONSTANT = Integer.valueOf(10000);
}
编译并反编译一下:
Javac CLPreparation.java
Javap v CLPreparation.class
可以在字节码中看到这样的额外初始化逻辑:
0: bipush 100
2: putstatic #2 // Field a:I
5: sipush 10000
8: invokestatic #3 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
11: putstatic #4 // Field INTEGER_CONSTANT:Ljava/lang/Integer;
这能让我们更清楚普通原始类型静态变量和引用类型即使是常量是需要额外调用putstatic等JVM指令的这些是在显式初始化阶段执行而不是准备阶段调用而原始类型常量则不需要这样的步骤。
关于类加载过程的更多细节有非常多的优秀资料进行介绍你可以参考大名鼎鼎的《深入理解Java虚拟机》一本非常好的入门书籍。我的建议是不要仅看教程最好能够想出代码实例去验证自己对某个方面的理解和判断这样不仅能加深理解还能够在未来的应用开发中使用到。
其实,类加载机制的范围实在太大,我从开发和部署的不同角度,各选取了一个典型扩展问题供你参考:
如果要真正理解双亲委派模型需要理解Java中类加载器的架构和职责至少要懂具体有哪些内建的类加载器这些是我上面的回答里没有提到的以及如何自定义类加载器
从应用角度解决某些类加载问题例如我的Java程序启动较慢有没有办法尽量减小Java类加载的开销
另外需要注意的是在Java 9中Jigsaw项目为Java提供了原生的模块化支持内建的类加载器结构和机制发生了明显变化。我会对此进行讲解希望能够避免一些未来升级中可能发生的问题。
知识扩展
首先从架构角度一起来看看Java 8以前各种类加载器的结构下面是三种Oracle JDK内建的类加载器。
启动类加载器Bootstrap Class-Loader加载 jre/lib下面的jar文件如rt.jar。它是个超级公民即使是在开启了Security Manager的时候JDK仍赋予了它加载的程序AllPermission。
对于做底层开发的工程师有的时候可能不得不去试图修改JDK的基础代码也就是通常意义上的核心类库我们可以使用下面的命令行参数。
# 指定新的bootclasspath替换java.*包的内部实现
java -Xbootclasspath:<your_boot_classpath> your_App
# a意味着append将指定目录添加到bootclasspath后面
java -Xbootclasspath/a:<your_dir> your_App
# p意味着prepend将指定目录添加到bootclasspath前面
java -Xbootclasspath/p:<your_dir> your_App
用法其实很易懂,例如,使用最常见的 “/p”既然是前置就有机会替换个别基础类的实现。
我们一般可以使用下面方法获取父加载器但是在通常的JDK/JRE实现中扩展类加载器getParent()都只能返回null。
public final ClassLoader getParent()
扩展类加载器Extension or Ext Class-Loader负责加载我们放到jre/lib/ext/目录下面的jar包这就是所谓的extension机制。该目录也可以通过设置 “java.ext.dirs”来覆盖。
java -Djava.ext.dirs=your_ext_dir HelloWorld
应用类加载器Application or App Class-Loader就是加载我们最熟悉的classpath的内容。这里有一个容易混淆的概念系统System类加载器通常来说其默认就是JDK内建的应用类加载器但是它同样是可能修改的比如
java -Djava.system.class.loader=com.yourcorp.YourClassLoader HelloWorld
如果我们指定了这个参数JDK内建的应用类加载器就会成为定制加载器的父亲这种方式通常用在类似需要改变双亲委派模式的场景。
具体请参考下图:
至于前面被问到的双亲委派模型,参考这个结构图更容易理解。试想,如果不同类加载器都自己加载需要的某个类型,那么就会出现多次重复加载,完全是种浪费。
通常类加载机制有三个基本特征:
双亲委派模型。但不是所有类加载都遵守这个模型有的时候启动类加载器所加载的类型是可能要加载用户代码的比如JDK内部的ServiceProvider/ServiceLoader机制用户可以在标准API框架上提供自己的实现JDK也需要提供些默认的参考实现。 例如Java 中JNDI、JDBC、文件系统、Cipher等很多方面都是利用的这种机制这种情况就不会用双亲委派模型去加载而是利用所谓的上下文加载器。
可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的,不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见。
在JDK 9中由于Jigsaw项目引入了Java平台模块化系统JPMSJava SE的源代码被划分为一系列模块。
类加载器,类文件容器等都发生了非常大的变化,我这里总结一下:
前面提到的-Xbootclasspath参数不可用了。API已经被划分到具体的模块所以上文中利用“-Xbootclasspath/p”替换某个Java核心类型代码实际上变成了对相应的模块进行的修补可以采用下面的解决方案
首先确认要修改的类文件已经编译好并按照对应模块假设是java.base结构存放 然后,给模块打补丁:
java --patch-module java.base=your_patch yourApp
扩展类加载器被重命名为平台类加载器Platform Class-Loader而且extension机制则被移除。也就意味着如果我们指定java.ext.dirs环境变量或者lib/ext目录存在JVM将直接返回错误建议解决办法就是将其放入classpath里。
部分不需要AllPermission的Java基础模块被降级到平台类加载器中相应的权限也被更精细粒度地限制起来。
rt.jar和tools.jar同样是被移除了JDK的核心类库以及相关资源被存储在jimage文件中并通过新的JRT文件系统访问而不是原有的JAR文件系统。虽然看起来很惊人但幸好对于大部分软件的兼容性影响其实是有限的更直接地影响是IDE等软件通常只要升级到新版本就可以了。
增加了Layer的抽象 JVM启动默认创建BootLayer开发者也可以自己去定义和实例化Layer可以更加方便的实现类似容器一般的逻辑抽象。
结合了Layer目前的JVM内部结构就变成了下面的层次内建类加载器都在BootLayer中其他Layer内部有自定义的类加载器不同版本模块可以同时工作在不同的Layer。
谈到类加载器,绕不过的一个话题是自定义类加载器,常见的场景有:
实现类似进程内隔离类加载器实际上用作不同的命名空间以提供类似容器、模块化的效果。例如两个模块依赖于某个类库的不同版本如果分别被不同的容器加载就可以互不干扰。这个方面的集大成者是Java EE和OSGI、JPMS等框架。
应用需要从不同的数据源获取类定义信息,例如网络数据源,而不是本地文件系统。
或者是需要自己操纵字节码,动态修改或者生成类型。
我们可以总体上简单理解自定义类加载过程:
通过指定名称,找到其二进制实现,这里往往就是自定义类加载器会“定制”的部分,例如,在特定数据源根据名字获取字节码,或者修改或生成字节码。
然后创建Class对象并完成类加载过程。二进制信息到Class对象的转换通常就依赖defineClass我们无需自己实现它是final方法。有了Class对象后续完成加载过程就顺理成章了。
具体实现我建议参考这个用例。
我在[专栏第1讲]中就提到了由于字节码是平台无关抽象而不是机器码所以Java需要类加载和解释、编译这些都导致Java启动变慢。谈了这么多类加载有没有什么通用办法不需要代码和其他工作量就可以降低类加载的开销呢
这个,可以有。
在第1讲中提到的AOT相当于直接编译成机器码降低的其实主要是解释和编译开销。但是其目前还是个试验特性支持的平台也有限比如JDK 9仅支持Linux x64所以局限性太大先暂且不谈。
还有就是较少人知道的AppCDSApplication Class-Data SharingCDS在Java 5中被引进但仅限于Bootstrap Class-loader在8u40中实现了AppCDS支持其他的类加载器在目前2018年初发布的JDK 10中已经开源。
简单来说AppCDS基本原理和工作过程是
首先JVM将类信息加载 解析成为元数据并根据是否需要修改将其分类为Read-Only部分和Read-Write部分。然后将这些元数据直接存储在文件系统中作为所谓的Shared Archive。命令很简单
Java -Xshare:dump -XX:+UseAppCDS -XX:SharedArchiveFile=<jsa> \
-XX:SharedClassListFile=<classlist> -XX:SharedArchiveConfigFile=<config_file>
第二在应用程序启动时指定归档文件并开启AppCDS。
Java -Xshare:on -XX:+UseAppCDS -XX:SharedArchiveFile=<jsa> yourApp
通过上面的命令JVM会通过内存映射技术直接映射到相应的地址空间免除了类加载、解析等各种开销。
AppCDS改善启动速度非常明显传统的Java EE应用一般可以提高20%~30%以上实验中使用Spark KMeans负载20个slave可以提高11%的启动速度。
与此同时降低内存footprint因为同一环境的Java进程间可以共享部分数据结构。前面谈到的两个实验平均可以减少10%以上的内存消耗。
当然,也不是没有局限性,如果恰好大量使用了运行时动态类加载,它的帮助就有限了。
今天我梳理了一下类加载的过程并针对Java新版中类加载机制发生的变化进行了相对全面的总结最后介绍了一个改善类加载速度的特性希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是谈谈什么是Jar Hell问题你有遇到过类似情况吗如何解决呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,187 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 有哪些方法可以在运行时动态生成一个Java类-极客时间
在开始今天的学习前,我建议你先复习一下【专栏第 6 讲】有关动态代理的内容。作为 Java 基础模块中的内容,考虑到不同基础的同学以及一个循序渐进的学习过程,我当时并没有在源码层面介绍动态代理的实现技术,仅进行了相应的技术比较。但是,有了【上一讲】的类加载的学习基础后,我想是时候该进行深入分析了。
今天我要问你的问题是,有哪些方法可以在运行时动态生成一个 Java 类?
典型回答
我们可以从常见的 Java 类来源分析,通常的开发过程是,开发者编写 Java 代码,调用 javac 编译成 class 文件,然后通过类加载机制载入 JVM就成为应用运行时可以使用的 Java 类了。
从上面过程得到启发,其中一个直接的方式是从源码入手,可以利用 Java 程序生成一段源码,然后保存到文件等,下面就只需要解决编译问题了。
有一种笨办法,直接用 ProcessBuilder 之类启动 javac 进程,并指定上面生成的文件作为输入,进行编译。最后,再利用类加载器,在运行时加载即可。
前面的方法,本质上还是在当前程序进程之外编译的,那么还有没有不这么 low 的办法呢?
你可以考虑使用 Java Compiler API这是 JDK 提供的标准 API里面提供了与 javac 对等的编译器功能具体请参考java.compiler相关文档。
进一步思考,我们一直围绕 Java 源码编译成为 JVM 可以理解的字节码,换句话说,只要是符合 JVM 规范的字节码,不管它是如何生成的,是不是都可以被 JVM 加载呢?我们能不能直接生成相应的字节码,然后交给类加载器去加载呢?
当然也可以,不过直接去写字节码难度太大,通常我们可以利用 Java 字节码操纵工具和类库来实现,比如在【专栏第 6 讲】中提到的ASM、Javassist、cglib 等。
考点分析
虽然曾经被视为黑魔法,但在当前复杂多变的开发环境中,在运行时动态生成逻辑并不是什么罕见的场景。重新审视我们谈到的动态代理,本质上不就是在特定的时机,去修改已有类型实现,或者创建新的类型。
明白了基本思路后,我还是围绕类加载机制进行展开,面试过程中面试官很可能从技术原理或实践的角度考察:
字节码和类加载到底是怎么无缝进行转换的?发生在整个类加载过程的哪一步?
如何利用字节码操纵技术,实现基本的动态代理逻辑?除了动态代理,字节码操纵技术还有那些应用
场景?
知识扩展
首先,我们来理解一下,类从字节码到 Class 对象的转换,在类加载过程中,这一步是通过下面的方法提供的功能,或者 defineClass 的其他本地对等实现。
protected final Class<?> defineClass(String name, byte[] b, int off, int len,
ProtectionDomain protectionDomain)
protected final Class<?> defineClass(String name, java.nio.ByteBuffer b,
ProtectionDomain protectionDomain)
我这里只选取了最基础的两个典型的 defineClass 实现Java 重载了几个不同的方法。
可以看出,只要能够生成出规范的字节码,不管是作为 byte 数组的形式,还是放到 ByteBuffer 里,都可以平滑地完成字节码到 Java 对象的转换过程。
JDK 提供的 defineClass 方法,最终都是本地代码实现的。
static native Class<?> defineClass1(ClassLoader loader, String name, byte[] b, int off, int len,
ProtectionDomain pd, String source);
static native Class<?> defineClass2(ClassLoader loader, String name, java.nio.ByteBuffer b,
int off, int len, ProtectionDomain pd,
String source);
更进一步,我们来看看 JDK dynamic proxy 的实现代码。你会发现,对应逻辑是实现在 ProxyBuilder 这个静态内部类中ProxyGenerator 生成字节码,并以 byte 数组的形式保存,然后通过调用 Unsafe 提供的 defineClass 入口。
byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
proxyName, interfaces.toArray(EMPTY_CLASS_ARRAY), accessFlags);
try {
Class<?> pc = UNSAFE.defineClass(proxyName, proxyClassFile,
0, proxyClassFile.length,
loader, null);
reverseProxyCache.sub(pc).putIfAbsent(loader, Boolean.TRUE);
return pc;
} catch (ClassFormatError e) {
// 如果出现ClassFormatError很可能是输入参数有问题比如ProxyGenerator有bug
}
前面理顺了二进制的字节码信息到 Class 对象的转换过程,似乎我们还没有分析如何生成自己需要的字节码,接下来一起来看看相关的字节码操纵逻辑。
JDK 内部动态代理的逻辑可以参考java.lang.reflect.ProxyGenerator的内部实现。我觉得可以认为这是种另类的字节码操纵技术其利用了DataOutputStrem提供的能力配合 hard-coded 的各种 JVM 指令实现方法,生成所需的字节码数组。你可以参考下面的示例代码。
private void codeLocalLoadStore(int lvar, int opcode, int opcode_0,
DataOutputStream out)
throws IOException
{
assert lvar >= 0 && lvar <= 0xFFFF;
// 根据变量数值以不同格式dump操作码
if (lvar <= 3) {
out.writeByte(opcode_0 + lvar);
} else if (lvar <= 0xFF) {
out.writeByte(opcode);
out.writeByte(lvar & 0xFF);
} else {
// 使用宽指令修饰符如果变量索引不能用无符号byte
out.writeByte(opc_wide);
out.writeByte(opcode);
out.writeShort(lvar & 0xFFFF);
}
}
这种实现方式的好处是没有太多依赖关系简单实用但是前提是你需要懂各种JVM 指令,知道怎么处理那些偏移地址等,实际门槛非常高,所以并不适合大多数的普通开发场景。
幸好Java 社区专家提供了各种从底层到更高抽象水平的字节码操作类库我们不需要什么都自己从头做。JDK 内部就集成了 ASM 类库,虽然并未作为公共 API 暴露出来但是它广泛应用在如java.lang.instrumentation API 底层实现或者Lambda Call Site生成的内部逻辑中这些代码的实现我就不在这里展开了如果你确实有兴趣或有需要可以参考类似 LamdaForm 的字节码生成逻辑java.lang.invoke.InvokerBytecodeGenerator。
从相对实用的角度思考一下,实现一个简单的动态代理,都要做什么?如何使用字节码操纵技术,走通这个过程呢?
对于一个普通的 Java 动态代理,其实现过程可以简化成为:
提供一个基础的接口作为被调用类型com.mycorp.HelloImpl和代理类之间的统一入口如 com.mycorp.Hello。
实现InvocationHandler对代理对象方法的调用会被分派到其 invoke 方法来真正实现动作。
通过 Proxy 类,调用其 newProxyInstance 方法,生成一个实现了相应基础接口的代理类实例,可以看下面的方法签名。
public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)
我们分析一下,动态代码生成是具体发生在什么阶段呢?
不错,就是在 newProxyInstance 生成代理类实例的时候。我选取了 JDK 自己采用的 ASM 作为示例,一起来看看用 ASM 实现的简要过程,请参考下面的示例代码片段。
第一步,生成对应的类,其实和我们去写 Java 代码很类似,只不过改为用 ASM 方法和指定参数,代替了我们书写的源码。
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cw.visit(V1_8, // 指定Java版本
ACC_PUBLIC, // 说明是public类型
"com/mycorp/HelloProxy", // 指定包和类的名称
null, // 签名null表示不是泛型
"java/lang/Object", // 指定父类
new String[]{ "com/mycorp/Hello" }); // 指定需要实现的接口
更进一步,我们可以按照需要为代理对象实例,生成需要的方法和逻辑。
MethodVisitor mv = cw.visitMethod(
ACC_PUBLIC, // 声明公共方法
"sayHello", // 方法名称
"()Ljava/lang/Object;", // 描述符
null, // 签名null表示不是泛型
null); // 可能抛出的异常,如果有,则指定字符串数组
mv.visitCode();
// 省略代码逻辑实现细节
cw.visitEnd(); // 结束类字节码生成
上面的代码虽然有些晦涩,但总体还是能多少理解其用意,不同的 visitX 方法提供了创建类型创建各种方法等逻辑。ASM API广泛的使用了Visitor模式如果你熟悉这个模式就会知道它所针对的场景是将算法和对象结构解耦非常适合字节码操纵的场合因为我们大部分情况都是依赖于特定结构修改或者添加新的方法、变量或者类型等。
按照前面的分析,字节码操作最后大都应该是生成 byte 数组ClassWriter 提供了一个简便的方法。
cw.toByteArray();
然后,就可以进入我们熟知的类加载过程了,我就不再赘述了,如果你对 ASM 的具体用法感兴趣,可以参考这个教程。
最后一个问题,字节码操纵技术,除了动态代理,还可以应用在什么地方?
这个技术似乎离我们日常开发遥远,但其实已经深入到各个方面,也许很多你现在正在使用的框架、工具就应用该技术,下面是我能想到的几个常见领域。
各种 Mock 框架
ORM 框架
IOC 容器
部分 Profiler 工具,或者运行时诊断工具等
生成形式化代码的工具
甚至可以认为,字节码操纵技术是工具和基础框架必不可少的部分,大大减少了开发者的负担。
今天我们探讨了更加深入的类加载和字节码操作方面技术。为了理解底层的原理我选取的例子是比较偏底层的、能力全面的类库如果实际项目中需要进行基础的字节码操作可以考虑使用更加高层次视角的类库例如Byte Buddy等。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?试想,假如我们有这样一个需求,需要添加某个功能,例如对某类型资源如网络通信的消耗进行统计,重点要求是,不开启时必须是零开销,而不是低开销,可以利用我们今天谈到的或者相关的技术实现吗?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,194 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 有哪些方法可以在运行时动态生成一个Java类
在开始今天的学习前,我建议你先复习一下[专栏第6讲]有关动态代理的内容。作为Java基础模块中的内容考虑到不同基础的同学以及一个循序渐进的学习过程我当时并没有在源码层面介绍动态代理的实现技术仅进行了相应的技术比较。但是有了[上一讲]的类加载的学习基础后,我想是时候该进行深入分析了。
今天我要问你的问题是有哪些方法可以在运行时动态生成一个Java类
典型回答
我们可以从常见的Java类来源分析通常的开发过程是开发者编写Java代码调用javac编译成class文件然后通过类加载机制载入JVM就成为应用运行时可以使用的Java类了。
从上面过程得到启发其中一个直接的方式是从源码入手可以利用Java程序生成一段源码然后保存到文件等下面就只需要解决编译问题了。
有一种笨办法直接用ProcessBuilder之类启动javac进程并指定上面生成的文件作为输入进行编译。最后再利用类加载器在运行时加载即可。
前面的方法本质上还是在当前程序进程之外编译的那么还有没有不这么low的办法呢
你可以考虑使用Java Compiler API这是JDK提供的标准API里面提供了与javac对等的编译器功能具体请参考java.compiler相关文档。
进一步思考我们一直围绕Java源码编译成为JVM可以理解的字节码换句话说只要是符合JVM规范的字节码不管它是如何生成的是不是都可以被JVM加载呢我们能不能直接生成相应的字节码然后交给类加载器去加载呢
当然也可以不过直接去写字节码难度太大通常我们可以利用Java字节码操纵工具和类库来实现比如在[专栏第6讲]中提到的ASM、Javassist、cglib等。
考点分析
虽然曾经被视为黑魔法,但在当前复杂多变的开发环境中,在运行时动态生成逻辑并不是什么罕见的场景。重新审视我们谈到的动态代理,本质上不就是在特定的时机,去修改已有类型实现,或者创建新的类型。
明白了基本思路后,我还是围绕类加载机制进行展开,面试过程中面试官很可能从技术原理或实践的角度考察:
字节码和类加载到底是怎么无缝进行转换的?发生在整个类加载过程的哪一步?
如何利用字节码操纵技术,实现基本的动态代理逻辑?
除了动态代理,字节码操纵技术还有那些应用场景?
知识扩展
首先我们来理解一下类从字节码到Class对象的转换在类加载过程中这一步是通过下面的方法提供的功能或者defineClass的其他本地对等实现。
protected final Class<?> defineClass(String name, byte[] b, int off, int len,
ProtectionDomain protectionDomain)
protected final Class<?> defineClass(String name, java.nio.ByteBuffer b,
ProtectionDomain protectionDomain)
我这里只选取了最基础的两个典型的defineClass实现Java重载了几个不同的方法。
可以看出只要能够生成出规范的字节码不管是作为byte数组的形式还是放到ByteBuffer里都可以平滑地完成字节码到Java对象的转换过程。
JDK提供的defineClass方法最终都是本地代码实现的。
static native Class<?> defineClass1(ClassLoader loader, String name, byte[] b, int off, int len,
ProtectionDomain pd, String source);
static native Class<?> defineClass2(ClassLoader loader, String name, java.nio.ByteBuffer b,
int off, int len, ProtectionDomain pd,
String source);
更进一步我们来看看JDK dynamic proxy的实现代码。你会发现对应逻辑是实现在ProxyBuilder这个静态内部类中ProxyGenerator生成字节码并以byte数组的形式保存然后通过调用Unsafe提供的defineClass入口。
byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
proxyName, interfaces.toArray(EMPTY_CLASS_ARRAY), accessFlags);
try {
Class<?> pc = UNSAFE.defineClass(proxyName, proxyClassFile,
0, proxyClassFile.length,
loader, null);
reverseProxyCache.sub(pc).putIfAbsent(loader, Boolean.TRUE);
return pc;
} catch (ClassFormatError e) {
// 如果出现ClassFormatError很可能是输入参数有问题比如ProxyGenerator有bug
}
前面理顺了二进制的字节码信息到Class对象的转换过程似乎我们还没有分析如何生成自己需要的字节码接下来一起来看看相关的字节码操纵逻辑。
JDK内部动态代理的逻辑可以参考java.lang.reflect.ProxyGenerator的内部实现。我觉得可以认为这是种另类的字节码操纵技术其利用了DataOutputStrem提供的能力配合hard-coded的各种JVM指令实现方法生成所需的字节码数组。你可以参考下面的示例代码。
private void codeLocalLoadStore(int lvar, int opcode, int opcode_0,
DataOutputStream out)
throws IOException
{
assert lvar >= 0 && lvar <= 0xFFFF;
// 根据变量数值以不同格式dump操作码
if (lvar <= 3) {
out.writeByte(opcode_0 + lvar);
} else if (lvar <= 0xFF) {
out.writeByte(opcode);
out.writeByte(lvar & 0xFF);
} else {
// 使用宽指令修饰符如果变量索引不能用无符号byte
out.writeByte(opc_wide);
out.writeByte(opcode);
out.writeShort(lvar & 0xFFFF);
}
}
这种实现方式的好处是没有太多依赖关系简单实用但是前提是你需要懂各种JVM指令知道怎么处理那些偏移地址等实际门槛非常高所以并不适合大多数的普通开发场景。
幸好Java社区专家提供了各种从底层到更高抽象水平的字节码操作类库我们不需要什么都自己从头做。JDK内部就集成了ASM类库虽然并未作为公共API暴露出来但是它广泛应用在如java.lang.instrumentation API底层实现或者Lambda Call Site生成的内部逻辑中这些代码的实现我就不在这里展开了如果你确实有兴趣或有需要可以参考类似LamdaForm的字节码生成逻辑java.lang.invoke.InvokerBytecodeGenerator。
从相对实用的角度思考一下,实现一个简单的动态代理,都要做什么?如何使用字节码操纵技术,走通这个过程呢?
对于一个普通的Java动态代理其实现过程可以简化成为
提供一个基础的接口作为被调用类型com.mycorp.HelloImpl和代理类之间的统一入口如com.mycorp.Hello。
实现InvocationHandler对代理对象方法的调用会被分派到其invoke方法来真正实现动作。
通过Proxy类调用其newProxyInstance方法生成一个实现了相应基础接口的代理类实例可以看下面的方法签名。
public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)
我们分析一下,动态代码生成是具体发生在什么阶段呢?
不错就是在newProxyInstance生成代理类实例的时候。我选取了JDK自己采用的ASM作为示例一起来看看用ASM实现的简要过程请参考下面的示例代码片段。
第一步生成对应的类其实和我们去写Java代码很类似只不过改为用ASM方法和指定参数代替了我们书写的源码。
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cw.visit(V1_8, // 指定Java版本
ACC_PUBLIC, // 说明是public类型
"com/mycorp/HelloProxy", // 指定包和类的名称
null, // 签名null表示不是泛型
"java/lang/Object", // 指定父类
new String[]{ "com/mycorp/Hello" }); // 指定需要实现的接口
更进一步,我们可以按照需要为代理对象实例,生成需要的方法和逻辑。
MethodVisitor mv = cw.visitMethod(
ACC_PUBLIC, // 声明公共方法
"sayHello", // 方法名称
"()Ljava/lang/Object;", // 描述符
null, // 签名null表示不是泛型
null); // 可能抛出的异常,如果有,则指定字符串数组
mv.visitCode();
// 省略代码逻辑实现细节
cw.visitEnd(); // 结束类字节码生成
上面的代码虽然有些晦涩但总体还是能多少理解其用意不同的visitX方法提供了创建类型创建各种方法等逻辑。ASM API广泛的使用了Visitor模式如果你熟悉这个模式就会知道它所针对的场景是将算法和对象结构解耦非常适合字节码操纵的场合因为我们大部分情况都是依赖于特定结构修改或者添加新的方法、变量或者类型等。
按照前面的分析字节码操作最后大都应该是生成byte数组ClassWriter提供了一个简便的方法。
cw.toByteArray();
然后就可以进入我们熟知的类加载过程了我就不再赘述了如果你对ASM的具体用法感兴趣可以参考这个教程。
最后一个问题,字节码操纵技术,除了动态代理,还可以应用在什么地方?
这个技术似乎离我们日常开发遥远,但其实已经深入到各个方面,也许很多你现在正在使用的框架、工具就应用该技术,下面是我能想到的几个常见领域。
各种Mock框架
ORM框架
IOC容器
部分Profiler工具或者运行时诊断工具等
生成形式化代码的工具
甚至可以认为,字节码操纵技术是工具和基础框架必不可少的部分,大大减少了开发者的负担。
今天我们探讨了更加深入的类加载和字节码操作方面技术。为了理解底层的原理我选取的例子是比较偏底层的、能力全面的类库如果实际项目中需要进行基础的字节码操作可以考虑使用更加高层次视角的类库例如Byte Buddy等。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?试想,假如我们有这样一个需求,需要添加某个功能,例如对某类型资源如网络通信的消耗进行统计,重点要求是,不开启时必须是零开销,而不是低开销,可以利用我们今天谈到的或者相关的技术实现吗?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,115 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 谈谈JVM内存区域的划分哪些区域可能发生OutOfMemoryError-极客时间
今天,我将从内存管理的角度,进一步探索 Java 虚拟机JVM。垃圾收集机制为我们打理了很多繁琐的工作大大提高了开发的效率但是垃圾收集也不是万能的懂得 JVM 内部的内存结构、工作机制,是设计高扩展性应用和诊断运行时问题的基础,也是 Java 工程师进阶的必备能力。
今天我要问你的问题是,谈谈 JVM 内存区域的划分,哪些区域可能发生 OutOfMemoryError
典型回答
通常可以把 JVM 内存区域分为下面几个方面,其中,有的区域是以线程为单位,而有的区域则是整个 JVM 进程唯一的。
首先程序计数器PCProgram Counter Register。在 JVM 规范中,每个线程都有它自己的程序计数器,并且任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址或者如果是在执行本地方法则是未指定值undefined
第二Java 虚拟机栈Java Virtual Machine Stack早期也叫 Java 栈。每个线程在创建时都会创建一个虚拟机栈其内部保存一个个的栈帧Stack Frame对应着一次次的 Java 方法调用。
前面谈程序计数器时提到了当前方法同理在一个时间点对应的只会有一个活动的栈帧通常叫作当前帧方法所在的类叫作当前类。如果在该方法中调用了其他方法对应的新的栈帧会被创建出来成为新的当前帧一直到它返回结果或者执行结束。JVM 直接对 Java 栈的操作只有两个,就是对栈帧的压栈和出栈。
栈帧中存储着局部变量表、操作数operand栈、动态链接、方法正常退出或者异常退出的定义等。
第三Heap它是 Java 内存管理的核心区域,用来放置 Java 对象实例,几乎所有创建的 Java 对象实例都是被直接分配在堆上。堆被所有的线程共享在虚拟机启动时我们指定的“Xmx”之类参数就是用来指定最大堆空间等指标。
理所当然,堆也是垃圾收集器重点照顾的区域,所以堆内空间还会被不同的垃圾收集器进行进一步的细分,最有名的就是新生代、老年代的划分。
第四方法区Method Area。这也是所有线程共享的一块内存区域用于存储所谓的元Meta数据例如类结构信息以及对应的运行时常量池、字段、方法代码等。
由于早期的 Hotspot JVM 实现很多人习惯于将方法区称为永久代Permanent Generation。Oracle JDK 8 中将永久代移除同时增加了元数据区Metaspace
第五运行时常量池Run-Time Constant Pool这是方法区的一部分。如果仔细分析过反编译的类文件结构你能看到版本号、字段、方法、超类、接口等各种信息还有一项信息就是常量池。Java 的常量池可以存放各种常量信息,不管是编译期生成的各种字面量,还是需要在运行时决定的符号引用,所以它比一般语言的符号表存储的信息更加宽泛。
第六本地方法栈Native Method Stack。它和 Java 虚拟机栈是非常相似的,支持对本地方法的调用,也是每个线程都会创建一个。在 Oracle Hotspot JVM 中,本地方法栈和 Java 虚拟机栈是在同一块儿区域,这完全取决于技术实现的决定,并未在规范中强制。
考点分析
这是个 JVM 领域的基础题目我给出的答案依据的是JVM 规范中运行时数据区定义,这也和大多数书籍和资料解读的角度类似。
JVM 内部的概念庞杂,对于初学者比较晦涩,我的建议是在工作之余,还是要去阅读经典书籍,比如我推荐过多次的《深入理解 Java 虚拟机》。
今天这一讲作为 Java 虚拟机内存管理的开篇,我会侧重于:
分析广义上的 JVM 内存结构或者说 Java 进程内存结构。
谈到 Java 内存模型,不可避免的要涉及 OutOfMemoryOOM问题那么在 Java 里面存在哪些种 OOM 的可能性,分别对应哪个内存区域的异常状况呢?
注意,具体 JVM 的内存结构,其实取决于其实现,不同厂商的 JVM或者同一厂商发布的不同版本都有可能存在一定差异。我在下面的分析中还会介绍 Oracle Hotspot JVM 的部分设计变化。
知识扩展
首先为了让你有个更加直观、清晰的印象我画了一个简单的内存结构图里面展示了我前面提到的堆、线程栈等区域并从数量上说明了什么是线程私有例如程序计数器、Java 栈等,以及什么是 Java 进程唯一。另外,还额外划分出了直接内存等区域。
这张图反映了实际中 Java 进程内存占用,与规范中定义的 JVM 运行时数据区之间的差别,它可以看作是运行时数据区的一个超集。毕竟理论上的视角和现实中的视角是有区别的,规范侧重的是通用的、无差别的部分,而对于应用开发者来说,只要是 Java 进程在运行时会占用,都会影响到我们的工程实践。
我这里简要介绍两点区别:
直接内存Direct Memory区域它就是我在【专栏第 12 讲】中谈到的 Direct Buffer 所直接分配的内存,也是个容易出现问题的地方。尽管,在 JVM 工程师的眼中,并不认为它是 JVM 内部内存的一部分,也并未体现 JVM 内存模型中。
JVM 本身是个本地程序还需要其他的内存去完成各种基本任务比如JIT Compiler 在运行时对热点方法进行编译,就会将编译后的方法储存在 Code Cache 里面GC 等功能需要运行在本地线程之中,类似部分都需要占用内存空间。这些是实现 JVM JIT 等功能的需要,但规范中并不涉及。
如果深入到 JVM 的实现细节,你会发现一些结论似乎有些模棱两可,比如:
Java 对象是不是都创建在堆上的呢?
我注意到有一些观点认为通过逃逸分析JVM 会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于 JVM 设计者的选择。据我所知Oracle Hotspot JVM 中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
目前很多书籍还是基于 JDK 7 以前的版本JDK 已经发生了很大变化Intern 字符串的缓存和静态变量曾经都被分配在永久代上而永久代已经被元数据区取代。但是Intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。
接下来,我们来看看什么是 OOM 问题,它可能在哪些内存区域发生?
首先OOM 如果通俗点儿说,就是 JVM 内存不够用了javadoc 中对OutOfMemoryError的解释是没有空闲内存并且垃圾收集器也无法提供更多内存。
这里面隐含着一层意思是,在抛出 OutOfMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间,例如:
我在【专栏第 4 讲】的引用机制分析中,已经提到了 JVM 会去尝试回收软引用指向的对象等。
在java.nio.BIts.reserveMemory() 方法中我们能清楚的看到System.gc() 会被调用,以清理空间,这也是为什么在大量使用 NIO 的 Direct Buffer 之类时,通常建议不要加下面的参数,毕竟是个最后的尝试,有可能避免一定的内存不足问题。
-XX:+DisableExplicitGC
当然也不是在任何情况下垃圾收集器都会被触发的比如我们去分配一个超大对象类似一个超大数组超过堆的最大值JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutOfMemoryError。
从我前面分析的数据区的角度,除了程序计数器,其他区域都有可能会因为可能的空间不足发生 OutOfMemoryError简单总结如下
堆内存不足是最常见的 OOM 原因之一抛出的错误信息是“java.lang.OutOfMemoryError:Java heap space”原因可能千奇百怪例如可能存在内存泄漏问题也很有可能就是堆的大小不合理比如我们要处理比较可观的数据量但是没有显式指定 JVM 堆大小或者指定数值偏小;或者出现 JVM 处理引用不及时,导致堆积起来,内存无法释放等。
而对于 Java 虚拟机栈和本地方法栈这里要稍微复杂一点。如果我们写一段程序不断的进行递归调用而且没有退出条件就会导致不断地进行压栈。类似这种情况JVM 实际会抛出 StackOverFlowError当然如果 JVM 试图去扩展栈空间的的时候失败,则会抛出 OutOfMemoryError。
对于老版本的 Oracle JDK因为永久代的大小是有限的并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 Intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息会标记出来和永久代相关“java.lang.OutOfMemoryError: PermGen space”。
随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM异常信息则变成了“java.lang.OutOfMemoryError: Metaspace”。
直接内存不足,也会导致 OOM这个已经【专栏第 11 讲】介绍过。
今天是 JVM 内存部分的第一讲,算是我们先进行了热身准备,我介绍了主要的内存区域,以及在不同版本 Hotspot JVM 内部的变化,并且分析了各区域是否可能产生 OutOfMemoryError以及 OOME 发生的典型情况。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,我在试图分配一个 100M bytes 大数组的时候发生了 OOME但是 GC 日志显示,明明堆上还有远不止 100M 的空间,你觉得可能问题的原因是什么?想要弄清楚这个问题,还需要什么信息呢?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,121 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 谈谈JVM内存区域的划分哪些区域可能发生OutOfMemoryError_
今天我将从内存管理的角度进一步探索Java虚拟机JVM。垃圾收集机制为我们打理了很多繁琐的工作大大提高了开发的效率但是垃圾收集也不是万能的懂得JVM内部的内存结构、工作机制是设计高扩展性应用和诊断运行时问题的基础也是Java工程师进阶的必备能力。
今天我要问你的问题是谈谈JVM内存区域的划分哪些区域可能发生OutOfMemoryError
典型回答
通常可以把JVM内存区域分为下面几个方面其中有的区域是以线程为单位而有的区域则是整个JVM进程唯一的。
首先程序计数器PCProgram Counter Register。在JVM规范中每个线程都有它自己的程序计数器并且任何时间一个线程都只有一个方法在执行也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址或者如果是在执行本地方法则是未指定值undefined
第二Java虚拟机栈Java Virtual Machine Stack早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈其内部保存一个个的栈帧Stack Frame对应着一次次的Java方法调用。
前面谈程序计数器时提到了当前方法同理在一个时间点对应的只会有一个活动的栈帧通常叫作当前帧方法所在的类叫作当前类。如果在该方法中调用了其他方法对应的新的栈帧会被创建出来成为新的当前帧一直到它返回结果或者执行结束。JVM直接对Java栈的操作只有两个就是对栈帧的压栈和出栈。
栈帧中存储着局部变量表、操作数operand栈、动态链接、方法正常退出或者异常退出的定义等。
第三Heap它是Java内存管理的核心区域用来放置Java对象实例几乎所有创建的Java对象实例都是被直接分配在堆上。堆被所有的线程共享在虚拟机启动时我们指定的“Xmx”之类参数就是用来指定最大堆空间等指标。
理所当然,堆也是垃圾收集器重点照顾的区域,所以堆内空间还会被不同的垃圾收集器进行进一步的细分,最有名的就是新生代、老年代的划分。
第四方法区Method Area。这也是所有线程共享的一块内存区域用于存储所谓的元Meta数据例如类结构信息以及对应的运行时常量池、字段、方法代码等。
由于早期的Hotspot JVM实现很多人习惯于将方法区称为永久代Permanent Generation。Oracle JDK 8中将永久代移除同时增加了元数据区Metaspace
第五运行时常量池Run-Time Constant Pool这是方法区的一部分。如果仔细分析过反编译的类文件结构你能看到版本号、字段、方法、超类、接口等各种信息还有一项信息就是常量池。Java的常量池可以存放各种常量信息不管是编译期生成的各种字面量还是需要在运行时决定的符号引用所以它比一般语言的符号表存储的信息更加宽泛。
第六本地方法栈Native Method Stack。它和Java虚拟机栈是非常相似的支持对本地方法的调用也是每个线程都会创建一个。在Oracle Hotspot JVM中本地方法栈和Java虚拟机栈是在同一块儿区域这完全取决于技术实现的决定并未在规范中强制。
考点分析
这是个JVM领域的基础题目我给出的答案依据的是JVM规范中运行时数据区定义这也和大多数书籍和资料解读的角度类似。
JVM内部的概念庞杂对于初学者比较晦涩我的建议是在工作之余还是要去阅读经典书籍比如我推荐过多次的《深入理解Java虚拟机》。
今天这一讲作为Java虚拟机内存管理的开篇我会侧重于
分析广义上的JVM内存结构或者说Java进程内存结构。
谈到Java内存模型不可避免的要涉及OutOfMemoryOOM问题那么在Java里面存在哪些种OOM的可能性分别对应哪个内存区域的异常状况呢
注意具体JVM的内存结构其实取决于其实现不同厂商的JVM或者同一厂商发布的不同版本都有可能存在一定差异。我在下面的分析中还会介绍Oracle Hotspot JVM的部分设计变化。
知识扩展
首先为了让你有个更加直观、清晰的印象我画了一个简单的内存结构图里面展示了我前面提到的堆、线程栈等区域并从数量上说明了什么是线程私有例如程序计数器、Java栈等以及什么是Java进程唯一。另外还额外划分出了直接内存等区域。-
这张图反映了实际中Java进程内存占用与规范中定义的JVM运行时数据区之间的差别它可以看作是运行时数据区的一个超集。毕竟理论上的视角和现实中的视角是有区别的规范侧重的是通用的、无差别的部分而对于应用开发者来说只要是Java进程在运行时会占用都会影响到我们的工程实践。
我这里简要介绍两点区别:
直接内存Direct Memory区域它就是我在[专栏第12讲]中谈到的Direct Buffer所直接分配的内存也是个容易出现问题的地方。尽管在JVM工程师的眼中并不认为它是JVM内部内存的一部分也并未体现JVM内存模型中。
JVM本身是个本地程序还需要其他的内存去完成各种基本任务比如JIT Compiler在运行时对热点方法进行编译就会将编译后的方法储存在Code Cache里面GC等功能需要运行在本地线程之中类似部分都需要占用内存空间。这些是实现JVM JIT等功能的需要但规范中并不涉及。
如果深入到JVM的实现细节你会发现一些结论似乎有些模棱两可比如
Java对象是不是都创建在堆上的呢
我注意到有一些观点认为通过逃逸分析JVM会在栈上分配那些不会逃逸的对象这在理论上是可行的但是取决于JVM设计者的选择。据我所知Oracle Hotspot JVM中并未这么做这一点在逃逸分析相关的文档里已经说明所以可以明确所有的对象实例都是创建在堆上。
目前很多书籍还是基于JDK 7以前的版本JDK已经发生了很大变化Intern字符串的缓存和静态变量曾经都被分配在永久代上而永久代已经被元数据区取代。但是Intern字符串缓存和静态变量并不是被转移到元数据区而是直接在堆上分配所以这一点同样符合前面一点的结论对象实例都是分配在堆上。
接下来我们来看看什么是OOM问题它可能在哪些内存区域发生
首先OOM如果通俗点儿说就是JVM内存不够用了javadoc中对OutOfMemoryError的解释是没有空闲内存并且垃圾收集器也无法提供更多内存。
这里面隐含着一层意思是在抛出OutOfMemoryError之前通常垃圾收集器会被触发尽其所能去清理出空间例如
我在[专栏第4讲]的引用机制分析中已经提到了JVM会去尝试回收软引用指向的对象等。
在java.nio.BIts.reserveMemory() 方法中我们能清楚的看到System.gc()会被调用以清理空间这也是为什么在大量使用NIO的Direct Buffer之类时通常建议不要加下面的参数毕竟是个最后的尝试有可能避免一定的内存不足问题。
-XX:+DisableExplicitGC
当然也不是在任何情况下垃圾收集器都会被触发的比如我们去分配一个超大对象类似一个超大数组超过堆的最大值JVM可以判断出垃圾收集并不能解决这个问题所以直接抛出OutOfMemoryError。
从我前面分析的数据区的角度除了程序计数器其他区域都有可能会因为可能的空间不足发生OutOfMemoryError简单总结如下
堆内存不足是最常见的OOM原因之一抛出的错误信息是“java.lang.OutOfMemoryError:Java heap space”原因可能千奇百怪例如可能存在内存泄漏问题也很有可能就是堆的大小不合理比如我们要处理比较可观的数据量但是没有显式指定JVM堆大小或者指定数值偏小或者出现JVM处理引用不及时导致堆积起来内存无法释放等。
而对于Java虚拟机栈和本地方法栈这里要稍微复杂一点。如果我们写一段程序不断的进行递归调用而且没有退出条件就会导致不断地进行压栈。类似这种情况JVM实际会抛出StackOverFlowError当然如果JVM试图去扩展栈空间的的时候失败则会抛出OutOfMemoryError。
对于老版本的Oracle JDK因为永久代的大小是有限的并且JVM对永久代垃圾回收常量池回收、卸载不再需要的类型非常不积极所以当我们不断添加新类型的时候永久代出现OutOfMemoryError也非常多见尤其是在运行时存在大量动态类型生成的场合类似Intern字符串缓存占用太多空间也会导致OOM问题。对应的异常信息会标记出来和永久代相关“java.lang.OutOfMemoryError: PermGen space”。
随着元数据区的引入方法区内存已经不再那么窘迫所以相应的OOM有所改观出现OOM异常信息则变成了“java.lang.OutOfMemoryError: Metaspace”。
直接内存不足也会导致OOM这个已经[专栏第11讲]介绍过。
今天是JVM内存部分的第一讲算是我们先进行了热身准备我介绍了主要的内存区域以及在不同版本Hotspot JVM内部的变化并且分析了各区域是否可能产生OutOfMemoryError以及OOME发生的典型情况。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是我在试图分配一个100M bytes大数组的时候发生了OOME但是GC日志显示明明堆上还有远不止100M的空间你觉得可能问题的原因是什么想要弄清楚这个问题还需要什么信息呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,236 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 如何监控和诊断JVM堆内和堆外内存使用-极客时间
上一讲我介绍了 JVM 内存区域的划分,总结了相关的一些概念,今天我将结合 JVM 参数、工具等方面,进一步分析 JVM 内存结构,包括外部资料相对较少的堆外部分。
今天我要问你的问题是,如何监控和诊断 JVM 堆内和堆外内存使用?
典型回答
了解 JVM 内存的方法有很多,具体能力范围也有区别,简单总结如下:
可以使用综合性的图形化工具,如 JConsole、VisualVM注意从 Oracle JDK 9 开始VisualVM 已经不再包含在 JDK 安装包中)等。这些工具具体使用起来相对比较直观,直接连接到 Java 进程,然后就可以在图形化界面里掌握内存使用情况。
以 JConsole 为例,其内存页面可以显示常见的堆内存和各种堆外部分使用状态。
也可以使用命令行工具进行运行时查询,如 jstat 和 jmap 等工具都提供了一些选项,可以查看堆、方法区等使用数据。
或者,也可以使用 jmap 等提供的命令生成堆转储Heap Dump文件然后利用 jhat 或 Eclipse MAT 等堆转储分析工具进行详细分析。
如果你使用的是 Tomcat、Weblogic 等 Java EE 服务器,这些服务器同样提供了内存管理相关的功能。
另外从某种程度上来说GC 日志等输出,同样包含着丰富的信息。
这里有一个相对特殊的部分,就是是堆外内存中的直接内存,前面的工具基本不适用,可以使用 JDK 自带的 Native Memory TrackingNMT特性它会从 JVM 本地内存分配的角度进行解读。
考点分析
今天选取的问题是 Java 内存管理相关的基础实践,对于普通的内存问题,掌握上面我给出的典型工具和方法就足够了。这个问题也可以理解为考察两个基本方面能力,第一,你是否真的理解了 JVM 的内部结构;第二,具体到特定内存区域,应该使用什么工具或者特性去定位,可以用什么参数调整。
对于 JConsole 等工具的使用细节我在专栏里不再赘述如果你还没有接触过你可以参考JConsole 官方教程。我这里特别推荐Java Mission ControlJMC这是一个非常强大的工具不仅仅能够使用JMX进行普通的管理、监控任务还可以配合Java Flight RecorderJFR技术以非常低的开销收集和分析 JVM 底层的 Profiling 和事件等信息。目前, Oracle 已经将其开源,如果你有兴趣请可以查看 OpenJDK 的Mission Control项目。
关于内存监控与诊断,我会在知识扩展部分结合 JVM 参数和特性,尽量从庞杂的概念和 JVM 参数选项中,梳理出相对清晰的框架:
细化对各部分内存区域的理解,堆内结构是怎样的?如何通过参数调整?
堆外内存到底包括哪些部分?具体大小受哪些因素影响?
知识扩展
今天的分析,我会结合相关 JVM 参数和工具,进行对比以加深你对内存区域更细粒度的理解。
首先,堆内部是什么结构?
对于堆内存,我在上一讲介绍了最常见的新生代和老年代的划分,其内部结构随着 JVM 的发展和新 GC 方式的引入,可以有不同角度的理解,下图就是年代视角的堆结构示意图。
你可以看到,按照通常的 GC 年代方式划分Java 堆内分为:
新生代
新生代是大部分对象创建和销毁的区域,在通常的 Java 应用中,绝大部分对象生命周期都是很短暂的。其内部又分为 Eden 区域,作为对象初始分配的区域;两个 Survivor有时候也叫 from、to 区域,被用来放置从 Minor GC 中保留下来的对象。
JVM 会随意选取一个 Survivor 区域作为“to”然后会在 GC 过程中进行区域间拷贝,也就是将 Eden 中存活下来的对象和 from 区域的对象拷贝到这个“to”区域。这种设计主要是为了防止内存的碎片化并进一步清理无用对象。
从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分Hotspot JVM 还有一个概念叫做 Thread Local Allocation BufferTLAB据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。这是 JVM 为每个线程分配的一个私有缓存区域否则多线程同时分配内存时为避免操作同一地址可能需要使用加锁等机制进而影响分配速度你可以参考下面的示意图。从图中可以看出TLAB 仍然在堆上,它是分配在 Eden 区域内的。其内部结构比较直观易懂start、end 就是起始地址top指针则表示已经分配到哪里了。所以我们分配新对象JVM 就会移动 top当 top 和 end 相遇时即表示该缓存已满JVM 会试图再从 Eden 里分配一块儿。
老年代
放置长生命周期的对象,通常都是从 Survivor 区域拷贝过来的对象。当然,也有特殊情况,我们知道普通的对象会被分配在 TLAB 上如果对象较大JVM 会试图直接分配在 Eden 其他位置上如果对象太大完全无法在新生代找到足够长的连续空闲空间JVM 就会直接分配到老年代。
永久代
这部分就是早期 Hotspot JVM 的方法区实现方式了,储存 Java 类元数据、常量池、Intern 字符串缓存,在 JDK 8 之后就不存在永久代这块儿了。
那么,我们如何利用 JVM 参数,直接影响堆和内部区域的大小呢?我来简单总结一下:
最大堆体积
-Xmx value
初始的最小堆体积
-Xms value
老年代和新生代的比例
-XX:NewRatio=value
默认情况下,这个数值是 2意味着老年代是新生代的 2 倍大;换句话说,新生代是堆大小的 1/3。
当然,也可以不用比例的方式调整新生代的大小,直接指定下面的参数,设定具体的内存大小数值。
-XX:NewSize=value
Eden 和 Survivor 的大小是按照比例设置的,如果 SurvivorRatio 是 8那么 Survivor 区域就是 Eden 的 18 大小,也就是新生代的 1/10因为 YoungGen=Eden + 2*SurvivorJVM 参数格式是
-XX:SurvivorRatio=value
TLAB 当然也可以调整JVM 实现了复杂的适应策略,如果你有兴趣可以参考这篇说明。
不知道你有没有注意到,我在年代视角的堆结构示意图也就是第一张图中,还标记出了 Virtual 区域,这是块儿什么区域呢?
在 JVM 内部,如果 Xms 小于 Xmx堆的大小并不会直接扩展到其上限也就是说保留的空间reserved大于实际能够使用的空间committed。当内存需求不断增长的时候JVM 会逐渐扩展新生代等区域的大小,所以 Virtual 区域代表的就是暂时不可用uncommitted的空间。
第二,分析完堆内空间,我们一起来看看 JVM 堆外内存到底包括什么?
在 JMC 或 JConsole 的内存管理界面,会统计部分非堆内存,但提供的信息相对有限,下图就是 JMC 活动内存池的截图。
接下来我会依赖 NMT 特性对 JVM 进行分析,它所提供的详细分类信息,非常有助于理解 JVM 内部实现。
首先来做些准备工作,开启 NMT 并选择 summary 模式,
-XX:NativeMemoryTracking=summary
为了方便获取和对比 NMT 输出,选择在应用退出时打印 NMT 统计信息
-XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics
然后,执行一个简单的在标准输出打印 HelloWorld 的程序,就可以得到下面的输出
我来仔细分析一下NMT 所表征的 JVM 本地内存使用:
第一部分非常明显是 Java 堆,我已经分析过使用什么参数调整,不再赘述。
第二部分是 Class 内存占用,它所统计的就是 Java 类元数据所占用的空间JVM 可以通过类似下面的参数调整其大小:
-XX:MaxMetaspaceSize=value
对于本例,因为 HelloWorld 没有什么用户类库所以其内存占用主要是启动类加载器Bootstrap加载的核心类库。你可以使用下面的小技巧调整启动类加载器元数据区这主要是为了对比以加深理解也许只有在 hack JDK 时才有实际意义。
-XX:InitialBootClassLoaderMetaspaceSize=30720
下面是 Thread这里既包括 Java 线程如程序主线程、Cleaner 线程等,也包括 GC 等本地线程。你有没有注意到,即使是一个 HelloWorld 程序,这个线程数量竟然还有 25。似乎有很多浪费设想我们要用 Java 作为 Serverless 运行时,每个 function 是非常短暂的,如何降低线程数量呢?
如果你充分理解了专栏讲解的内容,对 JVM 内部有了充分理解,思路就很清晰了:
JDK 9 的默认 GC 是 G1虽然它在较大堆场景表现良好但本身就会比传统的 Parallel GC 或者 Serial GC 之类复杂太多,所以要么降低其并行线程数目,要么直接切换 GC 类型;
JIT 编译默认是开启了 TieredCompilation 的,将其关闭,那么 JIT 也会变得简单,相应本地线程也会减少。
我们来对比一下,这是默认参数情况的输出:
下面是替换了默认 GC并关闭 TieredCompilation 的命令行
得到的统计信息如下,线程数目从 25 降到了 17消耗的内存也下降了大概 1/3。
接下来是 Code 统计信息,显然这是 CodeCache 相关内存,也就是 JIT compiler 存储编译热点方法等信息的地方JVM 提供了一系列参数可以限制其初始值和最大值等,例如:
-XX:InitialCodeCacheSize=value
-XX:ReservedCodeCacheSize=value
你可以设置下列 JVM 参数,也可以只设置其中一个,进一步判断不同参数对 CodeCache 大小的影响。
很明显CodeCache 空间下降非常大,这是因为我们关闭了复杂的 TieredCompilation而且还限制了其初始大小。
下面就是 GC 部分了就像我前面介绍的G1 等垃圾收集器其本身的设施和数据结构就非常复杂和庞大,例如 Remembered Set 通常都会占用 20%~30% 的堆空间。如果我把 GC 明确修改为相对简单的 Serial GC会有什么效果呢
使用命令:
-XX:+UseSerialGC
可见不仅总线程数大大降低25 → 13而且 GC 设施本身的内存开销就少了非常多。据我所知AWS Lambda 中 Java 运行时就是使用的 Serial GC可以大大降低单个 function 的启动和运行开销。
Compiler 部分,就是 JIT 的开销,显然关闭 TieredCompilation 会降低内存使用。
其他一些部分占比都非常低,通常也不会出现内存使用问题,请参考官方文档。唯一的例外就是 InternalJDK 11 以后在 Other 部分)部分,其统计信息包含着 Direct Buffer 的直接内存,这其实是堆外内存中比较敏感的部分,很多堆外内存 OOM 就发生在这里,请参考专栏第 12 讲的处理步骤。原则上 Direct Buffer 是不推荐频繁创建或销毁的,如果你怀疑直接内存区域有问题,通常可以通过类似 instrument 构造函数等手段,排查可能的问题。
JVM 内部结构就介绍到这里,主要目的是为了加深理解,很多方面只有在定制或调优 JVM 运行时才能真正涉及,随着微服务和 Serverless 等技术的兴起JDK 确实存在着为新特征的工作负载进行定制的需求。
今天我结合 JVM 参数和特性,系统地分析了 JVM 堆内和堆外内存结构,相信你一定对 JVM 内存结构有了比较深入的了解,在定制 Java 运行时或者处理 OOM 等问题的时候思路也会更加清晰。JVM 问题千奇百怪,如果你能快速将问题缩小,大致就能清楚问题可能出在哪里,例如如果定位到问题可能是堆内存泄漏,往往就已经有非常清晰的思路和工具可以去解决了。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,如果用程序的方式而不是工具,对 Java 内存使用进行监控,有哪些技术可以做到?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,217 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 如何监控和诊断JVM堆内和堆外内存使用
上一讲我介绍了JVM内存区域的划分总结了相关的一些概念今天我将结合JVM参数、工具等方面进一步分析JVM内存结构包括外部资料相对较少的堆外部分。
今天我要问你的问题是如何监控和诊断JVM堆内和堆外内存使用
典型回答
了解JVM内存的方法有很多具体能力范围也有区别简单总结如下
可以使用综合性的图形化工具如JConsole、VisualVM注意从Oracle JDK 9开始VisualVM已经不再包含在JDK安装包中等。这些工具具体使用起来相对比较直观直接连接到Java进程然后就可以在图形化界面里掌握内存使用情况。
以JConsole为例其内存页面可以显示常见的堆内存和各种堆外部分使用状态。
也可以使用命令行工具进行运行时查询如jstat和jmap等工具都提供了一些选项可以查看堆、方法区等使用数据。
或者也可以使用jmap等提供的命令生成堆转储Heap Dump文件然后利用jhat或Eclipse MAT等堆转储分析工具进行详细分析。
如果你使用的是Tomcat、Weblogic等Java EE服务器这些服务器同样提供了内存管理相关的功能。
另外从某种程度上来说GC日志等输出同样包含着丰富的信息。
这里有一个相对特殊的部分就是是堆外内存中的直接内存前面的工具基本不适用可以使用JDK自带的Native Memory TrackingNMT特性它会从JVM本地内存分配的角度进行解读。
考点分析
今天选取的问题是Java内存管理相关的基础实践对于普通的内存问题掌握上面我给出的典型工具和方法就足够了。这个问题也可以理解为考察两个基本方面能力第一你是否真的理解了JVM的内部结构第二具体到特定内存区域应该使用什么工具或者特性去定位可以用什么参数调整。
对于JConsole等工具的使用细节我在专栏里不再赘述如果你还没有接触过你可以参考JConsole官方教程。我这里特别推荐Java Mission ControlJMC这是一个非常强大的工具不仅仅能够使用JMX进行普通的管理、监控任务还可以配合Java Flight RecorderJFR技术以非常低的开销收集和分析JVM底层的Profiling和事件等信息。目前 Oracle已经将其开源如果你有兴趣请可以查看OpenJDK的Mission Control项目。
关于内存监控与诊断我会在知识扩展部分结合JVM参数和特性尽量从庞杂的概念和JVM参数选项中梳理出相对清晰的框架
细化对各部分内存区域的理解,堆内结构是怎样的?如何通过参数调整?
堆外内存到底包括哪些部分?具体大小受哪些因素影响?
知识扩展
今天的分析我会结合相关JVM参数和工具进行对比以加深你对内存区域更细粒度的理解。
首先,堆内部是什么结构?
对于堆内存我在上一讲介绍了最常见的新生代和老年代的划分其内部结构随着JVM的发展和新GC方式的引入可以有不同角度的理解下图就是年代视角的堆结构示意图。-
你可以看到按照通常的GC年代方式划分Java堆内分为
新生代
新生代是大部分对象创建和销毁的区域在通常的Java应用中绝大部分对象生命周期都是很短暂的。其内部又分为Eden区域作为对象初始分配的区域两个Survivor有时候也叫from、to区域被用来放置从Minor GC中保留下来的对象。
JVM会随意选取一个Survivor区域作为“to”然后会在GC过程中进行区域间拷贝也就是将Eden中存活下来的对象和from区域的对象拷贝到这个“to”区域。这种设计主要是为了防止内存的碎片化并进一步清理无用对象。
从内存模型而不是垃圾收集的角度对Eden区域继续进行划分Hotspot JVM还有一个概念叫做Thread Local Allocation BufferTLAB据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。这是JVM为每个线程分配的一个私有缓存区域否则多线程同时分配内存时为避免操作同一地址可能需要使用加锁等机制进而影响分配速度你可以参考下面的示意图。从图中可以看出TLAB仍然在堆上它是分配在Eden区域内的。其内部结构比较直观易懂start、end就是起始地址top指针则表示已经分配到哪里了。所以我们分配新对象JVM就会移动top当top和end相遇时即表示该缓存已满JVM会试图再从Eden里分配一块儿。-
老年代
放置长生命周期的对象通常都是从Survivor区域拷贝过来的对象。当然也有特殊情况我们知道普通的对象会被分配在TLAB上如果对象较大JVM会试图直接分配在Eden其他位置上如果对象太大完全无法在新生代找到足够长的连续空闲空间JVM就会直接分配到老年代。
永久代
这部分就是早期Hotspot JVM的方法区实现方式了储存Java类元数据、常量池、Intern字符串缓存在JDK 8之后就不存在永久代这块儿了。
那么我们如何利用JVM参数直接影响堆和内部区域的大小呢我来简单总结一下
最大堆体积
-Xmx value
初始的最小堆体积
-Xms value
老年代和新生代的比例
-XX:NewRatio=value
默认情况下这个数值是2意味着老年代是新生代的2倍大换句话说新生代是堆大小的1/3。
当然,也可以不用比例的方式调整新生代的大小,直接指定下面的参数,设定具体的内存大小数值。
-XX:NewSize=value
Eden和Survivor的大小是按照比例设置的如果SurvivorRatio是8那么Survivor区域就是Eden的1/8大小也就是新生代的1/10因为YoungGen=Eden + 2*SurvivorJVM参数格式是
-XX:SurvivorRatio=value
TLAB当然也可以调整JVM实现了复杂的适应策略如果你有兴趣可以参考这篇说明。
不知道你有没有注意到我在年代视角的堆结构示意图也就是第一张图中还标记出了Virtual区域这是块儿什么区域呢
在JVM内部如果Xms小于Xmx堆的大小并不会直接扩展到其上限也就是说保留的空间reserved大于实际能够使用的空间committed。当内存需求不断增长的时候JVM会逐渐扩展新生代等区域的大小所以Virtual区域代表的就是暂时不可用uncommitted的空间。
第二分析完堆内空间我们一起来看看JVM堆外内存到底包括什么
在JMC或JConsole的内存管理界面会统计部分非堆内存但提供的信息相对有限下图就是JMC活动内存池的截图。-
接下来我会依赖NMT特性对JVM进行分析它所提供的详细分类信息非常有助于理解JVM内部实现。
首先来做些准备工作开启NMT并选择summary模式
-XX:NativeMemoryTracking=summary
为了方便获取和对比NMT输出选择在应用退出时打印NMT统计信息
-XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics
然后执行一个简单的在标准输出打印HelloWorld的程序就可以得到下面的输出-
我来仔细分析一下NMT所表征的JVM本地内存使用
第一部分非常明显是Java堆我已经分析过使用什么参数调整不再赘述。
第二部分是Class内存占用它所统计的就是Java类元数据所占用的空间JVM可以通过类似下面的参数调整其大小
-XX:MaxMetaspaceSize=value
对于本例因为HelloWorld没有什么用户类库所以其内存占用主要是启动类加载器Bootstrap加载的核心类库。你可以使用下面的小技巧调整启动类加载器元数据区这主要是为了对比以加深理解也许只有在hack JDK时才有实际意义。
-XX:InitialBootClassLoaderMetaspaceSize=30720
下面是Thread这里既包括Java线程如程序主线程、Cleaner线程等也包括GC等本地线程。你有没有注意到即使是一个HelloWorld程序这个线程数量竟然还有25。似乎有很多浪费设想我们要用Java作为Serverless运行时每个function是非常短暂的如何降低线程数量呢-
如果你充分理解了专栏讲解的内容对JVM内部有了充分理解思路就很清晰了-
JDK 9的默认GC是G1虽然它在较大堆场景表现良好但本身就会比传统的Parallel GC或者Serial GC之类复杂太多所以要么降低其并行线程数目要么直接切换GC类型-
JIT编译默认是开启了TieredCompilation的将其关闭那么JIT也会变得简单相应本地线程也会减少。-
我们来对比一下,这是默认参数情况的输出:-
下面是替换了默认GC并关闭TieredCompilation的命令行-
得到的统计信息如下线程数目从25降到了17消耗的内存也下降了大概1/3。-
接下来是Code统计信息显然这是CodeCache相关内存也就是JIT compiler存储编译热点方法等信息的地方JVM提供了一系列参数可以限制其初始值和最大值等例如
-XX:InitialCodeCacheSize=value
-XX:ReservedCodeCacheSize=value
你可以设置下列JVM参数也可以只设置其中一个进一步判断不同参数对CodeCache大小的影响。-
-
很明显CodeCache空间下降非常大这是因为我们关闭了复杂的TieredCompilation而且还限制了其初始大小。
下面就是GC部分了就像我前面介绍的G1等垃圾收集器其本身的设施和数据结构就非常复杂和庞大例如Remembered Set通常都会占用20%~30%的堆空间。如果我把GC明确修改为相对简单的Serial GC会有什么效果呢
使用命令:
-XX:+UseSerialGC
可见不仅总线程数大大降低25 → 13而且GC设施本身的内存开销就少了非常多。据我所知AWS Lambda中Java运行时就是使用的Serial GC可以大大降低单个function的启动和运行开销。
Compiler部分就是JIT的开销显然关闭TieredCompilation会降低内存使用。
其他一些部分占比都非常低通常也不会出现内存使用问题请参考官方文档。唯一的例外就是InternalJDK 11以后在Other部分部分其统计信息包含着Direct Buffer的直接内存这其实是堆外内存中比较敏感的部分很多堆外内存OOM就发生在这里请参考专栏第12讲的处理步骤。原则上Direct Buffer是不推荐频繁创建或销毁的如果你怀疑直接内存区域有问题通常可以通过类似instrument构造函数等手段排查可能的问题。
JVM内部结构就介绍到这里主要目的是为了加深理解很多方面只有在定制或调优JVM运行时才能真正涉及随着微服务和Serverless等技术的兴起JDK确实存在着为新特征的工作负载进行定制的需求。
今天我结合JVM参数和特性系统地分析了JVM堆内和堆外内存结构相信你一定对JVM内存结构有了比较深入的了解在定制Java运行时或者处理OOM等问题的时候思路也会更加清晰。JVM问题千奇百怪如果你能快速将问题缩小大致就能清楚问题可能出在哪里例如如果定位到问题可能是堆内存泄漏往往就已经有非常清晰的思路和工具可以去解决了。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是如果用程序的方式而不是工具对Java内存使用进行监控有哪些技术可以做到?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,170 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 Java常见的垃圾收集器有哪些-极客时间
垃圾收集机制是 Java 的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展, Java 的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。
今天我要问你的问题是Java 常见的垃圾收集器有哪些?
典型回答
实际上垃圾收集器GCGarbage Collector是和具体 JVM 实现紧密相关的不同厂商IBM、Oracle不同版本的 JVM提供的选择也不同。接下来我来谈谈最主流的 Oracle JDK。
Serial GC它是最古老的垃圾收集器“Serial”体现在其收集工作是单线程的并且在进行垃圾收集过程中会进入臭名昭著的“Stop-The-World”状态。当然其单线程设计也意味着精简的 GC 实现,无需维护复杂的数据结构,初始化也简单,所以一直是 Client 模式下 JVM 的默认选项。
从年代的角度,通常将其老年代实现单独称作 Serial Old它采用了标记 - 整理Mark-Compact算法区别于新生代的复制算法。
Serial GC 的对应 JVM 参数是:
-XX:+UseSerialGC
ParNew GC很明显是个新生代 GC 实现,它实际是 Serial GC 的多线程版本,最常见的应用场景是配合老年代的 CMS GC 工作,下面是对应参数
-XX:+UseConcMarkSweepGC -XX:+UseParNewGC
CMSConcurrent Mark Sweep GC基于标记 - 清除Mark-Sweep算法设计目标是尽量减少停顿时间这一点对于 Web 等反应时间敏感的应用非常重要,一直到今天,仍然有很多系统使用 CMS GC。但是CMS 采用的标记 - 清除算法,存在着内存碎片化问题,所以难以避免在长时间运行等情况下发生 full GC导致恶劣的停顿。另外既然强调了并发ConcurrentCMS 会占用更多 CPU 资源,并和用户线程争抢。
Parallel GC在早期 JDK 8 等版本中,它是 server 模式 JVM 的默认 GC 选择,也被称作是吞吐量优先的 GC。它的算法和 Serial GC 比较相似,尽管实现要复杂的多,其特点是新生代和老年代 GC 都是并行进行的,在常见的服务器环境中更加高效。
开启选项是:
-XX:+UseParallelGC
另外Parallel GC 引入了开发者友好的配置项我们可以直接设置暂停时间或吞吐量等目标JVM 会自动进行适应性调整,例如下面参数:
-XX:MaxGCPauseMillis=value
-XX:GCTimeRatio=N // GC时间和用户时间比例 = 1 / (N+1)
G1 GC 这是一种兼顾吞吐量和停顿时间的 GC 实现,是 Oracle JDK 9 以后的默认 GC 选项。G1 可以直观的设定停顿时间的目标,相比于 CMS GCG1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。
G1 GC 仍然存在着年代的概念,但是其内存结构并不是简单的条带式划分,而是类似棋盘的一个个 region。Region 之间是复制算法,但整体上实际可看作是标记 - 整理Mark-Compact算法可以有效地避免内存碎片尤其是当 Java 堆非常大的时候G1 的优势更加明显。
G1 吞吐量和停顿表现都非常不错,并且仍然在不断地完善,与此同时 CMS 已经在 JDK 9 中被标记为废弃deprecated所以 G1 GC 值得你深入掌握。
考点分析
今天的问题是考察你对 GC 的了解GC 是 Java 程序员的面试常见题目,但是并不是每个人都有机会或者必要对 JVM、GC 进行深入了解,我前面的总结是为不熟悉这部分内容的同学提供一个整体的印象。
对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。在今天的讲解中,我侧重介绍比较通用、基础性的部分:
垃圾收集的算法有哪些?如何判断一个对象是否可以回收?
垃圾收集器工作的基本流程。
另外Java 一直处于非常迅速的发展之中,在最新的 JDK 实现中,还有多种新的 GC我会在最后补充除了前面提到的垃圾收集器看看还有哪些值得关注的选择。
知识扩展
垃圾收集的原理和基础概念
第一,自动垃圾收集的前提是清楚哪些内存可以被释放。这一点可以结合我前面对 Java 类加载和内存结构的分析,来思考一下。
主要就是两个方面,最主要部分就是对象实例,都是存储在堆上的;还有就是方法区中的元数据等信息,例如类型不再使用,卸载该 Java 类似乎是很合理的。
对于对象实例收集,主要是两种基本算法,引用计数和可达性分析。
引用计数算法,顾名思义,就是为对象添加一个引用计数,用于记录对象被引用的情况,如果计数为 0即表示对象可回收。这是很多语言的资源回收选择例如因人工智能而更加火热的 Python它更是同时支持引用计数和垃圾收集机制。具体哪种最优是要看场景的业界有大规模实践中仅保留引用计数机制以提高吞吐量的尝试。
Java 并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
另外就是 Java 选择的可达性分析Java 的各种引用关系,在某种程度上,将可达性问题还进一步复杂化,具体请参考【专栏第 4 讲】这种类型的垃圾收集通常叫作追踪性垃圾收集Tracing Garbage Collection。其原理简单来说就是将对象及其引用关系看作一个图选定活动的对象作为 GC Roots然后跟踪引用链条如果一个对象和 GC Roots 之间不可达也就是不存在引用链条那么即可认为是可回收对象。JVM 会把虚拟机栈和本地方法栈中正在引用的对象、静态属性引用的对象和常量,作为 GC Roots。
方法区无用元数据的回收比较复杂我简单梳理一下。还记得我对类加载器的分类吧一般来说初始化类加载器加载的类型是不会进行类卸载unload而普通的类型的卸载往往是要求相应自定义类加载器本身被回收所以大量使用动态类型的场合需要防止元数据区或者早期的永久代不会 OOM。在 8u40 以后的 JDK 中,下面参数已经是默认的:
-XX:+ClassUnloadingWithConcurrentMark
第二,常见的垃圾收集算法,我认为总体上有个了解,理解相应的原理和优缺点,就已经足够了,其主要分为三类:
复制Copying算法我前面讲到的新生代 GC基本都是基于复制算法过程就如专栏上一讲所介绍的将活着的对象复制到 to 区域,拷贝过程中将对象顺序放置,就可以避免内存碎片化。
这么做的代价是,既然要进行复制,既要提前预留内存空间,有一定的浪费;另外,对于 G1 这种分拆成为大量 region 的 GC复制而不是移动意味着 GC 需要维护 region 之间对象引用关系,这个开销也不小,不管是内存占用或者时间开销。
标记 - 清除Mark-Sweep算法首先进行标记工作标识出所有要回收的对象然后进行清除。这么做除了标记、清除过程效率有限另外就是不可避免的出现碎片化问题这就导致其不适合特别大的堆否则一旦出现 Full GC暂停时间可能根本无法接受。
标记 - 整理Mark-Compact类似于标记 - 清除,但为避免内存碎片化,它会在清理过程中将对象移动,以确保移动后的对象占用连续的内存空间。
注意,这些只是基本的算法思路,实际 GC 实现过程要复杂的多,目前还在发展中的前沿 GC 都是复合算法,并且并行和并发兼备。
如果对这方面的算法有兴趣,可以参考一本比较有意思的书《垃圾回收的算法与实现》,虽然其内容并不是围绕 Java 垃圾收集,但是对通用算法讲解比较形象。
垃圾收集过程的理解
我在【专栏上一讲】对堆结构进行了比较详细的划分,在垃圾收集的过程,对应到 Eden、Survivor、Tenured 等区域会发生什么变化呢?
这实际上取决于具体的 GC 方式,先来熟悉一下通常的垃圾收集流程,我画了一系列示意图,希望能有助于你理解清楚这个过程。
第一Java 应用不断创建对象,通常都是分配在 Eden 区域,当其空间占用达到一定阈值时,触发 minor GC。仍然被引用的对象绿色方块存活下来被复制到 JVM 选择的 Survivor 区域,而没有被引用的对象(黄色方块)则被回收。注意,我给存活对象标记了“数字 1”这是为了表明对象的存活时间。
第二, 经过一次 Minor GCEden 就会空闲下来,直到再次达到 Minor GC 触发条件,这时候,另外一个 Survivor 区域则会成为 to 区域Eden 区域的存活对象和 From 区域对象,都会被复制到 to 区域,并且存活的年龄计数会被加 1。
第三, 类似第二步的过程会发生很多次直到有对象年龄计数达到阈值这时候就会发生所谓的晋升Promotion过程如下图所示超过阈值的对象会被晋升到老年代。这个阈值是可以通过参数指定
-XX:MaxTenuringThreshold=&lt;N&gt;
后面就是老年代 GC具体取决于选择的 GC 选项,对应不同的算法。下面是一个简单标记 - 整理算法过程示意图,老年代中的无用对象被清除后, GC 会将对象进行整理,以防止内存碎片化。
通常我们把老年代 GC 叫作 Major GC将对整个堆进行的清理叫作 Full GC但是这个也没有那么绝对因为不同的老年代 GC 算法其实表现差异很大,例如 CMS“concurrent”就体现在清理工作是与工作线程一起并发运行的。
GC 的新发展
GC 仍然处于飞速发展之中,目前的默认选项 G1 GC 在不断的进行改进,很多我们原来认为的缺点,例如串行的 Full GC、Card Table 扫描的低效等,都已经被大幅改进,例如, JDK 10 以后Full GC 已经是并行运行,在很多场景下,其表现还略优于 Parallel GC 的并行 Full GC 实现。
即使是 Serial GC虽然比较古老但是简单的设计和实现未必就是过时的它本身的开销不管是 GC 相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在 Serverless 等新的应用场景下Serial GC 找到了新的舞台。
比较不幸的是 CMS GC因为其算法的理论缺陷等原因虽然现在还有非常大的用户群体但是已经被标记为废弃如果没有组织主动承担 CMS 的维护,很有可能会在未来版本移除。
如果你有关注目前尚处于开发中的 JDK 11你会发现JDK 又增加了两种全新的 GC 方式,分别是:
Epsilon GC简单说就是个不做垃圾收集的 GC似乎有点奇怪有的情况下例如在进行性能测试的时候可能需要明确判断 GC 本身产生了多大的开销,这就是其典型应用场景。
ZGC这是 Oracle 开源出来的一个超级 GC 实现,具备令人惊讶的扩展能力,比如支持 T bytes 级别的堆大小,并且保证绝大部分情况下,延迟都不会超过 10 ms。虽然目前还处于实验阶段仅支持 Linux 64 位的平台,但其已经表现出的能力和潜力都非常令人期待。
当然,其他厂商也提供了各种独具一格的 GC 实现,例如比较有名的低延迟 GCZing和Shenandoah等有兴趣请参考我提供的链接。
今天,作为 GC 系列的第一讲,我从整体上梳理了目前的主流 GC 实现,包括基本原理和算法,并结合我前面介绍过的内存结构,对简要的垃圾收集过程进行了介绍,希望能够对你的相关实践有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天谈了一堆的理论,思考一个实践中的问题,你通常使用什么参数去打开 GC 日志呢?还会额外添加哪些选项?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,159 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 Java常见的垃圾收集器有哪些
垃圾收集机制是Java的招牌能力极大地提高了开发效率。如今垃圾收集几乎成为现代语言的标配即使经过如此长时间的发展 Java的垃圾收集机制仍然在不断的演进中不同大小的设备、不同特征的应用场景对垃圾收集提出了新的挑战这当然也是面试的热点。
今天我要问你的问题是Java常见的垃圾收集器有哪些
典型回答
实际上垃圾收集器GCGarbage Collector是和具体JVM实现紧密相关的不同厂商IBM、Oracle不同版本的JVM提供的选择也不同。接下来我来谈谈最主流的Oracle JDK。
Serial GC它是最古老的垃圾收集器“Serial”体现在其收集工作是单线程的并且在进行垃圾收集过程中会进入臭名昭著的“Stop-The-World”状态。当然其单线程设计也意味着精简的GC实现无需维护复杂的数据结构初始化也简单所以一直是Client模式下JVM的默认选项。-
从年代的角度通常将其老年代实现单独称作Serial Old它采用了标记-整理Mark-Compact算法区别于新生代的复制算法。-
Serial GC的对应JVM参数是
-XX:+UseSerialGC
ParNew GC很明显是个新生代GC实现它实际是Serial GC的多线程版本最常见的应用场景是配合老年代的CMS GC工作下面是对应参数
-XX:+UseConcMarkSweepGC -XX:+UseParNewGC
CMSConcurrent Mark Sweep GC基于标记-清除Mark-Sweep算法设计目标是尽量减少停顿时间这一点对于Web等反应时间敏感的应用非常重要一直到今天仍然有很多系统使用CMS GC。但是CMS采用的标记-清除算法存在着内存碎片化问题所以难以避免在长时间运行等情况下发生full GC导致恶劣的停顿。另外既然强调了并发ConcurrentCMS会占用更多CPU资源并和用户线程争抢。
Parallel GC在早期JDK 8等版本中它是server模式JVM的默认GC选择也被称作是吞吐量优先的GC。它的算法和Serial GC比较相似尽管实现要复杂的多其特点是新生代和老年代GC都是并行进行的在常见的服务器环境中更加高效。-
开启选项是:
-XX:+UseParallelGC
另外Parallel GC引入了开发者友好的配置项我们可以直接设置暂停时间或吞吐量等目标JVM会自动进行适应性调整例如下面参数
-XX:MaxGCPauseMillis=value
-XX:GCTimeRatio=N // GC时间和用户时间比例 = 1 / (N+1)
G1 GC这是一种兼顾吞吐量和停顿时间的GC实现是Oracle JDK 9以后的默认GC选项。G1可以直观的设定停顿时间的目标相比于CMS GCG1未必能做到CMS在最好情况下的延时停顿但是最差情况要好很多。-
G1 GC仍然存在着年代的概念但是其内存结构并不是简单的条带式划分而是类似棋盘的一个个region。Region之间是复制算法但整体上实际可看作是标记-整理Mark-Compact算法可以有效地避免内存碎片尤其是当Java堆非常大的时候G1的优势更加明显。-
G1吞吐量和停顿表现都非常不错并且仍然在不断地完善与此同时CMS已经在JDK 9中被标记为废弃deprecated所以G1 GC值得你深入掌握。
考点分析
今天的问题是考察你对GC的了解GC是Java程序员的面试常见题目但是并不是每个人都有机会或者必要对JVM、GC进行深入了解我前面的总结是为不熟悉这部分内容的同学提供一个整体的印象。
对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。在今天的讲解中,我侧重介绍比较通用、基础性的部分:
垃圾收集的算法有哪些?如何判断一个对象是否可以回收?
垃圾收集器工作的基本流程。
另外Java一直处于非常迅速的发展之中在最新的JDK实现中还有多种新的GC我会在最后补充除了前面提到的垃圾收集器看看还有哪些值得关注的选择。
知识扩展
垃圾收集的原理和基础概念
第一自动垃圾收集的前提是清楚哪些内存可以被释放。这一点可以结合我前面对Java类加载和内存结构的分析来思考一下。
主要就是两个方面最主要部分就是对象实例都是存储在堆上的还有就是方法区中的元数据等信息例如类型不再使用卸载该Java类似乎是很合理的。
对于对象实例收集,主要是两种基本算法,引用计数和可达性分析。
引用计数算法顾名思义就是为对象添加一个引用计数用于记录对象被引用的情况如果计数为0即表示对象可回收。这是很多语言的资源回收选择例如因人工智能而更加火热的Python它更是同时支持引用计数和垃圾收集机制。具体哪种最优是要看场景的业界有大规模实践中仅保留引用计数机制以提高吞吐量的尝试。-
Java并没有选择引用计数是因为其存在一个基本的难题也就是很难处理循环引用关系。
另外就是Java选择的可达性分析Java的各种引用关系在某种程度上将可达性问题还进一步复杂化具体请参考[专栏第4讲]这种类型的垃圾收集通常叫作追踪性垃圾收集Tracing Garbage Collection。其原理简单来说就是将对象及其引用关系看作一个图选定活动的对象作为 GC Roots然后跟踪引用链条如果一个对象和GC Roots之间不可达也就是不存在引用链条那么即可认为是可回收对象。JVM会把虚拟机栈和本地方法栈中正在引用的对象、静态属性引用的对象和常量作为GC Roots。
方法区无用元数据的回收比较复杂我简单梳理一下。还记得我对类加载器的分类吧一般来说初始化类加载器加载的类型是不会进行类卸载unload而普通的类型的卸载往往是要求相应自定义类加载器本身被回收所以大量使用动态类型的场合需要防止元数据区或者早期的永久代不会OOM。在8u40以后的JDK中下面参数已经是默认的
-XX:+ClassUnloadingWithConcurrentMark
第二,常见的垃圾收集算法,我认为总体上有个了解,理解相应的原理和优缺点,就已经足够了,其主要分为三类:
复制Copying算法我前面讲到的新生代GC基本都是基于复制算法过程就如[专栏上一讲]所介绍的将活着的对象复制到to区域拷贝过程中将对象顺序放置就可以避免内存碎片化。-
这么做的代价是既然要进行复制既要提前预留内存空间有一定的浪费另外对于G1这种分拆成为大量region的GC复制而不是移动意味着GC需要维护region之间对象引用关系这个开销也不小不管是内存占用或者时间开销。
标记-清除Mark-Sweep算法首先进行标记工作标识出所有要回收的对象然后进行清除。这么做除了标记、清除过程效率有限另外就是不可避免的出现碎片化问题这就导致其不适合特别大的堆否则一旦出现Full GC暂停时间可能根本无法接受。
标记-整理Mark-Compact类似于标记-清除,但为避免内存碎片化,它会在清理过程中将对象移动,以确保移动后的对象占用连续的内存空间。
注意这些只是基本的算法思路实际GC实现过程要复杂的多目前还在发展中的前沿GC都是复合算法并且并行和并发兼备。
如果对这方面的算法有兴趣可以参考一本比较有意思的书《垃圾回收的算法与实现》虽然其内容并不是围绕Java垃圾收集但是对通用算法讲解比较形象。
垃圾收集过程的理解
我在[专栏上一讲]对堆结构进行了比较详细的划分在垃圾收集的过程对应到Eden、Survivor、Tenured等区域会发生什么变化呢
这实际上取决于具体的GC方式先来熟悉一下通常的垃圾收集流程我画了一系列示意图希望能有助于你理解清楚这个过程。
第一Java应用不断创建对象通常都是分配在Eden区域当其空间占用达到一定阈值时触发minor GC。仍然被引用的对象绿色方块存活下来被复制到JVM选择的Survivor区域而没有被引用的对象黄色方块则被回收。注意我给存活对象标记了“数字1”这是为了表明对象的存活时间。-
第二, 经过一次Minor GCEden就会空闲下来直到再次达到Minor GC触发条件这时候另外一个Survivor区域则会成为to区域Eden区域的存活对象和From区域对象都会被复制到to区域并且存活的年龄计数会被加1。-
第三, 类似第二步的过程会发生很多次直到有对象年龄计数达到阈值这时候就会发生所谓的晋升Promotion过程如下图所示超过阈值的对象会被晋升到老年代。这个阈值是可以通过参数指定
-XX:MaxTenuringThreshold=<N>
后面就是老年代GC具体取决于选择的GC选项对应不同的算法。下面是一个简单标记-整理算法过程示意图,老年代中的无用对象被清除后, GC会将对象进行整理以防止内存碎片化。
通常我们把老年代GC叫作Major GC将对整个堆进行的清理叫作Full GC但是这个也没有那么绝对因为不同的老年代GC算法其实表现差异很大例如CMS“concurrent”就体现在清理工作是与工作线程一起并发运行的。
GC的新发展
GC仍然处于飞速发展之中目前的默认选项G1 GC在不断的进行改进很多我们原来认为的缺点例如串行的Full GC、Card Table扫描的低效等都已经被大幅改进例如 JDK 10以后Full GC已经是并行运行在很多场景下其表现还略优于Parallel GC的并行Full GC实现。
即使是Serial GC虽然比较古老但是简单的设计和实现未必就是过时的它本身的开销不管是GC相关数据结构的开销还是线程的开销都是非常小的所以随着云计算的兴起在Serverless等新的应用场景下Serial GC找到了新的舞台。
比较不幸的是CMS GC因为其算法的理论缺陷等原因虽然现在还有非常大的用户群体但是已经被标记为废弃如果没有组织主动承担CMS的维护很有可能会在未来版本移除。
如果你有关注目前尚处于开发中的JDK 11你会发现JDK又增加了两种全新的GC方式分别是
Epsilon GC简单说就是个不做垃圾收集的GC似乎有点奇怪有的情况下例如在进行性能测试的时候可能需要明确判断GC本身产生了多大的开销这就是其典型应用场景。
ZGC这是Oracle开源出来的一个超级GC实现具备令人惊讶的扩展能力比如支持T bytes级别的堆大小并且保证绝大部分情况下延迟都不会超过10 ms。虽然目前还处于实验阶段仅支持Linux 64位的平台但其已经表现出的能力和潜力都非常令人期待。
当然其他厂商也提供了各种独具一格的GC实现例如比较有名的低延迟GCZing和Shenandoah等有兴趣请参考我提供的链接。
今天作为GC系列的第一讲我从整体上梳理了目前的主流GC实现包括基本原理和算法并结合我前面介绍过的内存结构对简要的垃圾收集过程进行了介绍希望能够对你的相关实践有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天谈了一堆的理论思考一个实践中的问题你通常使用什么参数去打开GC日志呢还会额外添加哪些选项
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,202 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 谈谈你的GC调优思路-极客时间
我发现,目前不少外部资料对 G1 的介绍大多还停留在 JDK 7 或更早期的实现,很多结论已经存在较大偏差,甚至一些过去的 GC 选项已经不再推荐使用。所以,今天我会选取新版 JDK 中的默认 G1 GC 作为重点进行详解,并且我会从调优实践的角度,分析典型场景和调优思路。下面我们一起来更新下这方面的知识。
今天我要问你的问题是,谈谈你的 GC 调优思路?
典型回答
谈到调优,这一定是针对特定场景、特定目的的事情, 对于 GC 调优来说首先就需要清楚调优的目标是什么从性能的角度看通常关注三个方面内存占用footprint、延时latency和吞吐量throughput大多数情况下调优会侧重于其中一个或者两个方面的目标很少有情况可以兼顾三个不同的角度。当然除了上面通常的三个方面也可能需要考虑其他 GC 相关的场景例如OOM 也可能与不合理的 GC 相关参数有关或者应用启动速度方面的需求GC 也会是个考虑的方面。
基本的调优思路可以总结为:
理解应用需求和问题,确定调优目标。假设,我们开发了一个应用服务,但发现偶尔会出现性能抖动,出现较长的服务停顿。评估用户可接受的响应时间和业务量,将目标简化为,希望 GC 暂停尽量控制在 200ms 以内,并且保证一定标准的吞吐量。
掌握 JVM 和 GC 的状态,定位具体的问题,确定真的有 GC 调优的必要。具体有很多方法,比如,通过 jstat 等工具查看 GC 等相关状态,可以开启 GC 日志,或者是利用操作系统提供的诊断工具等。例如,通过追踪 GC 日志,就可以查找是不是 GC 在特定时间发生了长时间的暂停,进而导致了应用响应不及时。
这里需要思考,选择的 GC 类型是否符合我们的应用特征,如果是,具体问题表现在哪里,是 Minor GC 过长,还是 Mixed GC 等出现异常停顿情况;如果不是,考虑切换到什么类型,如 CMS 和 G1 都是更侧重于低延迟的 GC 选项。
通过分析确定具体调整的参数或者软硬件配置。
验证是否达到调优目标,如果达到目标,即可以考虑结束调优;否则,重复完成分析、调整、验证这个过程。
考点分析
今天考察的 GC 调优问题是 JVM 调优的一个基础方面,很多 JVM 调优需求,最终都会落实在 GC 调优上或者与其相关,我提供的是一个常见的思路。
真正快速定位和解决具体问题,还是需要对 JVM 和 GC 知识的掌握,以及实际调优经验的总结,有的时候甚至是源自经验积累的直觉判断。面试官可能会继续问项目中遇到的真实问题,如果你能清楚、简要地介绍其上下文,然后将诊断思路和调优实践过程表述出来,会是个很好的加分项。
专栏虽然无法提供具体的项目经验,但是可以帮助你掌握常见的调优思路和手段,这不管是面试还是在实际工作中都是很有帮助的。另外,我会还会从下面不同角度进行补充:
【上一讲】中我已经谈到,涉及具体的 GC 类型JVM 的实际表现要更加复杂。目前G1 已经成为新版 JDK 的默认选择,所以值得你去深入理解。
因为 G1 GC 一直处在快速发展之中,我会侧重它的演进变化,尤其是行为和配置相关的变化。并且,同样是因为 JVM 的快速发展,即使是收集 GC 日志等方面也发生了较大改进,这也是为什么我在上一讲留给你的思考题是有关日志相关选项,看完讲解相信你会很惊讶。
从 GC 调优实践的角度,理解通用问题的调优思路和手段。
知识扩展
首先,先来整体了解一下 G1 GC 的内部结构和主要机制。
从内存区域的角度G1 同样存在着年代的概念,但是与我前面介绍的内存结构很不一样,其内部是类似棋盘状的一个个 region 组成,请参考下面的示意图。
region 的大小是一致的,数值是在 1M 到 32M 字节之间的一个 2 的幂值数JVM 会尽量划分 2048 个左右、同等大小的 region这点可以从源码heapRegionBounds.hpp中看到。当然这个数字既可以手动调整G1 也会根据堆大小自动进行调整。
在 G1 实现中,年代是个逻辑概念,具体体现在,一部分 region 是作为 Eden一部分作为 Survivor除了意料之中的 Old regionG1 会将超过 region 50% 大小的对象(在应用中,通常是 byte 或 char 数组)归类为 Humongous 对象,并放置在相应的 region 中。逻辑上Humongous region 算是老年代的一部分,因为复制这样的大对象是很昂贵的操作,并不适合新生代 GC 的复制算法。
你可以思考下 region 设计有什么副作用?
例如region 大小和大对象很难保证一致,这会导致空间的浪费。不知道你有没有注意到,我的示意图中有的区域是 Humongous 颜色,但没有用名称标记,这是为了表示,特别大的对象是可能占用超过一个 region 的。并且region 太小不合适会令你在分配大对象时更难找到连续空间这是一个长久存在的情况请参考OpenJDK 社区的讨论。这本质也可以看作是 JVM 的 bug尽管解决办法也非常简单直接设置较大的 region 大小,参数如下:
-XX:G1HeapRegionSize=<N, 例如16>M
从 GC 算法的角度G1 选择的是复合算法,可以简化理解为:
在新生代G1 采用的仍然是并行的复制算法,所以同样会发生 Stop-The-World 的暂停。
在老年代大部分情况下都是并发标记而整理Compact则是和新生代 GC 时捎带进行,并且不是整体性的整理,而是增量进行的。
我在【上一讲】曾经介绍过,习惯上人们喜欢把新生代 GCYoung GC叫作 Minor GC老年代 GC 叫作 Major GC区别于整体性的 Full GC。但是现代 GC 中,这种概念已经不再准确,对于 G1 来说:
Minor GC 仍然存在,虽然具体过程会有区别,会涉及 Remembered Set 等相关处理。
老年代回收,则是依靠 Mixed GC。并发标记结束后JVM 就有足够的信息进行垃圾收集Mixed GC 不仅同时会清理 Eden、Survivor 区域,而且还会清理部分 Old 区域。可以通过设置下面的参数,指定触发阈值,并且设定最多被包含在一次 Mixed GC 中的 region 比例。
XX:G1MixedGCLiveThresholdPercent
XX:G1OldCSetRegionThresholdPercent
从 G1 内部运行的角度,下面的示意图描述了 G1 正常运行时的状态流转变化,当然,在发生逃逸失败等情况下,就会触发 Full GC。
G1 相关概念非常多,有一个重点就是 Remembered Set用于记录和维护 region 之间对象的引用关系。为什么需要这么做呢?试想,新生代 GC 是复制算法,也就是说,类似对象从 Eden 或者 Survivor 到 to 区域的“移动”,其实是“复制”,本质上是一个新的对象。在这个过程中,需要必须保证老年代到新生代的跨区引用仍然有效。下面的示意图说明了相关设计。
G1 的很多开销都是源自 Remembered Set例如它通常约占用 Heap 大小的 20% 或更高,这可是非常可观的比例。并且,我们进行对象复制的时候,因为需要扫描和更改 Card Table 的信息,这个速度影响了复制的速度,进而影响暂停时间。
描述 G1 内部的资料很多,我就不重复了,如果你想了解更多内部结构和算法等,我建议参考一些具体的介绍,书籍方面我推荐 Charlie Hunt 等撰写的《Java Performance Companion》。
接下来,我介绍下大家可能还不了解的 G1 行为变化,它们在一定程度上解决了专栏其他讲中提到的部分困扰,如类型卸载不及时的问题。
上面提到了 Humongous 对象的分配和回收这是很多内存问题的来源Humongous region 作为老年代的一部分,通常认为它会在并发标记结束后才进行回收,但是在新版 G1 中Humongous 对象回收采取了更加激进的策略。
我们知道 G1 记录了老年代 region 间对象引用Humongous 对象数量有限,所以能够快速的知道是否有老年代对象引用它。如果没有,能够阻止它被回收的唯一可能,就是新生代是否有对象引用了它,但这个信息是可以在 Young GC 时就知道的,所以完全可以在 Young GC 中就进行 Humongous 对象的回收,不用像其他老年代对象那样,等待并发标记结束。
我在【专栏第 5 讲】,提到了在 8u20 以后字符串排重的特性在垃圾收集过程中G1 会把新创建的字符串对象放入队列中,然后在 Young GC 之后,并发地(不会 STW将内部数据char 数组JDK 9 以后是 byte 数组)一致的字符串进行排重,也就是将其引用同一个数组。你可以使用下面参数激活:
-XX:+UseStringDeduplication
注意,这种排重虽然可以节省不少内存空间,但这种并发操作会占用一些 CPU 资源,也会导致 Young GC 稍微变慢。
类型卸载是个长期困扰一些 Java 应用的问题,在【专栏第 25 讲】中,我介绍了一个类只有当加载它的自定义类加载器被回收后,才能被卸载。元数据区替换了永久代之后有所改善,但还是可能出现问题。
G1 的类型卸载有什么改进吗很多资料中都谈到G1 只有在发生 Full GC 时才进行类型卸载,但这显然不是我们想要的。你可以加上下面的参数查看类型卸载:
幸好现代的 G1 已经不是如此了8u40 以后G1 增加并默认开启下面的选项:
-XX:+TraceClassUnloading
也就是说在并发标记阶段结束后JVM 即进行类型卸载。
我们知道老年代对象回收,基本要等待并发标记结束。这意味着,如果并发标记结束不及时,导致堆已满,但老年代空间还没完成回收,就会触发 Full GC所以触发并发标记的时机很重要。早期的 G1 调优中,通常会设置下面参数,但是很难给出一个普适的数值,往往要根据实际运行结果调整
-XX:InitiatingHeapOccupancyPercent
在 JDK 9 之后的 G1 实现中,这种调整需求会少很多,因为 JVM 只会将该参数作为初始值,会在运行时进行采样,获取统计数据,然后据此动态调整并发标记启动时机。对应的 JVM 参数如下,默认已经开启:
-XX:+G1UseAdaptiveIHOP
在现有的资料中,大多指出 G1 的 Full GC 是最差劲的单线程串行 GC。其实如果采用的是最新的 JDK你会发现 Full GC 也是并行进行的了,在通用场景中的表现还优于 Parallel GC 的 Full GC 实现。
当然,还有很多其他的改变,比如更快的 Card Table 扫描等,这里不再展开介绍,因为它们并不带来行为的变化,基本不影响调优选择。
前面介绍了 G1 的内部机制,并且穿插了部分调优建议,下面从整体上给出一些调优的建议。
首先,建议尽量升级到较新的 JDK 版本,从上面介绍的改进就可以看到,很多人们常常讨论的问题,其实升级 JDK 就可以解决了。
第二,掌握 GC 调优信息收集途径。掌握尽量全面、详细、准确的信息,是各种调优的基础,不仅仅是 GC 调优。我们来看看打开 GC 日志,这似乎是很简单的事情,可是你确定真的掌握了吗?
除了常用的两个选项,
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
还有一些非常有用的日志选项,很多特定问题的诊断都是要依赖这些选项:
-XX:+PrintAdaptiveSizePolicy // 打印G1 Ergonomics相关信息
我们知道 GC 内部一些行为是适应性的触发的,利用 PrintAdaptiveSizePolicy我们就可以知道为什么 JVM 做出了一些可能我们不希望发生的动作。例如G1 调优的一个基本建议就是避免进行大量的 Humongous 对象分配,如果 Ergonomics 信息说明发生了这一点,那么就可以考虑要么增大堆的大小,要么直接将 region 大小提高。
如果是怀疑出现引用清理不及时的情况,则可以打开下面选项,掌握到底是哪里出现了堆积。
-XX:+PrintReferenceGC
另外,建议开启选项下面的选项进行并行引用处理。
-XX:+PrintReferenceGC
需要注意的一点是JDK 9 中 JVM 和 GC 日志机构进行了重构,其实我前面提到的 PrintGCDetails 已经被标记为废弃,而 PrintGCDateStamps 已经被移除,指定它会导致 JVM 无法启动。可以使用下面的命令查询新的配置参数。
最后,来看一些通用实践,理解了我前面介绍的内部结构和机制,很多结论就一目了然了,例如:
如果发现 Young GC 非常耗时,这很可能就是因为新生代太大了,我们可以考虑减小新生代的最小比例。
-XX:G1NewSizePercent
降低其最大值同样对降低 Young GC 延迟有帮助。
-XX:G1MaxNewSizePercent
如果我们直接为 G1 设置较小的延迟目标值,也会起到减小新生代的效果,虽然会影响吞吐量。
如果是 Mixed GC 延迟较长,我们应该怎么做呢?
还记得前面说的,部分 Old region 会被包含进 Mixed GC减少一次处理的 region 个数,就是个直接的选择之一。
我在上面已经介绍了 G1OldCSetRegionThresholdPercent 控制其最大值,还可以利用下面参数提高 Mixed GC 的个数,当前默认值是 8Mixed GC 数量增多,意味着每次被包含的 region 减少。
-XX:G1MixedGCCountTarget
今天的内容算是抛砖引玉更多内容你可以参考G1 调优指南等远不是几句话可以囊括的。需要注意的是也要避免过度调优G1 对大堆非常友好,其运行机制也需要浪费一定的空间,有时候稍微多给堆一些空间,比进行苛刻的调优更加实用。
今天我梳理了基本的 GC 调优思路,并对 G1 内部结构以及最新的行为变化进行了详解。总的来说G1 的调优相对简单、直观,因为可以直接设定暂停时间等目标,并且其内部引入了各种智能的自适应机制,希望这一切的努力,能够让你在日常应用开发时更加高效。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,定位 Full GC 发生的原因,有哪些方式?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,215 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 谈谈你的GC调优思路_
我发现目前不少外部资料对G1的介绍大多还停留在JDK 7或更早期的实现很多结论已经存在较大偏差甚至一些过去的GC选项已经不再推荐使用。所以今天我会选取新版JDK中的默认G1 GC作为重点进行详解并且我会从调优实践的角度分析典型场景和调优思路。下面我们一起来更新下这方面的知识。
今天我要问你的问题是谈谈你的GC调优思路
典型回答
谈到调优,这一定是针对特定场景、特定目的的事情, 对于GC调优来说首先就需要清楚调优的目标是什么从性能的角度看通常关注三个方面内存占用footprint、延时latency和吞吐量throughput大多数情况下调优会侧重于其中一个或者两个方面的目标很少有情况可以兼顾三个不同的角度。当然除了上面通常的三个方面也可能需要考虑其他GC相关的场景例如OOM也可能与不合理的GC相关参数有关或者应用启动速度方面的需求GC也会是个考虑的方面。
基本的调优思路可以总结为:
理解应用需求和问题确定调优目标。假设我们开发了一个应用服务但发现偶尔会出现性能抖动出现较长的服务停顿。评估用户可接受的响应时间和业务量将目标简化为希望GC暂停尽量控制在200ms以内并且保证一定标准的吞吐量。
掌握JVM和GC的状态定位具体的问题确定真的有GC调优的必要。具体有很多方法比如通过jstat等工具查看GC等相关状态可以开启GC日志或者是利用操作系统提供的诊断工具等。例如通过追踪GC日志就可以查找是不是GC在特定时间发生了长时间的暂停进而导致了应用响应不及时。
这里需要思考选择的GC类型是否符合我们的应用特征如果是具体问题表现在哪里是Minor GC过长还是Mixed GC等出现异常停顿情况如果不是考虑切换到什么类型如CMS和G1都是更侧重于低延迟的GC选项。
通过分析确定具体调整的参数或者软硬件配置。
验证是否达到调优目标,如果达到目标,即可以考虑结束调优;否则,重复完成分析、调整、验证这个过程。
考点分析
今天考察的GC调优问题是JVM调优的一个基础方面很多JVM调优需求最终都会落实在GC调优上或者与其相关我提供的是一个常见的思路。
真正快速定位和解决具体问题还是需要对JVM和GC知识的掌握以及实际调优经验的总结有的时候甚至是源自经验积累的直觉判断。面试官可能会继续问项目中遇到的真实问题如果你能清楚、简要地介绍其上下文然后将诊断思路和调优实践过程表述出来会是个很好的加分项。
专栏虽然无法提供具体的项目经验,但是可以帮助你掌握常见的调优思路和手段,这不管是面试还是在实际工作中都是很有帮助的。另外,我会还会从下面不同角度进行补充:
[上一讲]中我已经谈到涉及具体的GC类型JVM的实际表现要更加复杂。目前G1已经成为新版JDK的默认选择所以值得你去深入理解。
因为G1 GC一直处在快速发展之中我会侧重它的演进变化尤其是行为和配置相关的变化。并且同样是因为JVM的快速发展即使是收集GC日志等方面也发生了较大改进这也是为什么我在上一讲留给你的思考题是有关日志相关选项看完讲解相信你会很惊讶。
从GC调优实践的角度理解通用问题的调优思路和手段。
知识扩展
首先先来整体了解一下G1 GC的内部结构和主要机制。
从内存区域的角度G1同样存在着年代的概念但是与我前面介绍的内存结构很不一样其内部是类似棋盘状的一个个region组成请参考下面的示意图。-
region的大小是一致的数值是在1M到32M字节之间的一个2的幂值数JVM会尽量划分2048个左右、同等大小的region这点可以从源码heapRegionBounds.hpp中看到。当然这个数字既可以手动调整G1也会根据堆大小自动进行调整。
在G1实现中年代是个逻辑概念具体体现在一部分region是作为Eden一部分作为Survivor除了意料之中的Old regionG1会将超过region 50%大小的对象在应用中通常是byte或char数组归类为Humongous对象并放置在相应的region中。逻辑上Humongous region算是老年代的一部分因为复制这样的大对象是很昂贵的操作并不适合新生代GC的复制算法。
你可以思考下region设计有什么副作用
例如region大小和大对象很难保证一致这会导致空间的浪费。不知道你有没有注意到我的示意图中有的区域是Humongous颜色但没有用名称标记这是为了表示特别大的对象是可能占用超过一个region的。并且region太小不合适会令你在分配大对象时更难找到连续空间这是一个长久存在的情况请参考OpenJDK社区的讨论。这本质也可以看作是JVM的bug尽管解决办法也非常简单直接设置较大的region大小参数如下
-XX:G1HeapRegionSize=<N, 例如16>M
从GC算法的角度G1选择的是复合算法可以简化理解为
在新生代G1采用的仍然是并行的复制算法所以同样会发生Stop-The-World的暂停。
在老年代大部分情况下都是并发标记而整理Compact则是和新生代GC时捎带进行并且不是整体性的整理而是增量进行的。
我在[上一讲]曾经介绍过习惯上人们喜欢把新生代GCYoung GC叫作Minor GC老年代GC叫作Major GC区别于整体性的Full GC。但是现代GC中这种概念已经不再准确对于G1来说
Minor GC仍然存在虽然具体过程会有区别会涉及Remembered Set等相关处理。
老年代回收则是依靠Mixed GC。并发标记结束后JVM就有足够的信息进行垃圾收集Mixed GC不仅同时会清理Eden、Survivor区域而且还会清理部分Old区域。可以通过设置下面的参数指定触发阈值并且设定最多被包含在一次Mixed GC中的region比例。
XX:G1MixedGCLiveThresholdPercent
XX:G1OldCSetRegionThresholdPercent
从G1内部运行的角度下面的示意图描述了G1正常运行时的状态流转变化当然在发生逃逸失败等情况下就会触发Full GC。-
G1相关概念非常多有一个重点就是Remembered Set用于记录和维护region之间对象的引用关系。为什么需要这么做呢试想新生代GC是复制算法也就是说类似对象从Eden或者Survivor到to区域的“移动”其实是“复制”本质上是一个新的对象。在这个过程中需要必须保证老年代到新生代的跨区引用仍然有效。下面的示意图说明了相关设计。-
G1的很多开销都是源自Remembered Set例如它通常约占用Heap大小的20%或更高这可是非常可观的比例。并且我们进行对象复制的时候因为需要扫描和更改Card Table的信息这个速度影响了复制的速度进而影响暂停时间。
描述G1内部的资料很多我就不重复了如果你想了解更多内部结构和算法等我建议参考一些具体的介绍书籍方面我推荐Charlie Hunt等撰写的《Java Performance Companion》。
接下来我介绍下大家可能还不了解的G1行为变化它们在一定程度上解决了专栏其他讲中提到的部分困扰如类型卸载不及时的问题。
上面提到了Humongous对象的分配和回收这是很多内存问题的来源Humongous region作为老年代的一部分通常认为它会在并发标记结束后才进行回收但是在新版G1中Humongous对象回收采取了更加激进的策略。-
我们知道G1记录了老年代region间对象引用Humongous对象数量有限所以能够快速的知道是否有老年代对象引用它。如果没有能够阻止它被回收的唯一可能就是新生代是否有对象引用了它但这个信息是可以在Young GC时就知道的所以完全可以在Young GC中就进行Humongous对象的回收不用像其他老年代对象那样等待并发标记结束。
我在[专栏第5讲]提到了在8u20以后字符串排重的特性在垃圾收集过程中G1会把新创建的字符串对象放入队列中然后在Young GC之后并发地不会STW将内部数据char数组JDK 9以后是byte数组一致的字符串进行排重也就是将其引用同一个数组。你可以使用下面参数激活
-XX:+UseStringDeduplication
注意这种排重虽然可以节省不少内存空间但这种并发操作会占用一些CPU资源也会导致Young GC稍微变慢。
类型卸载是个长期困扰一些Java应用的问题在[专栏第25讲]中,我介绍了一个类只有当加载它的自定义类加载器被回收后,才能被卸载。元数据区替换了永久代之后有所改善,但还是可能出现问题。
G1的类型卸载有什么改进吗很多资料中都谈到G1只有在发生Full GC时才进行类型卸载但这显然不是我们想要的。你可以加上下面的参数查看类型卸载
-XX:+TraceClassUnloading
幸好现代的G1已经不是如此了8u40以后G1增加并默认开启下面的选项
-XX:+ClassUnloadingWithConcurrentMark
也就是说在并发标记阶段结束后JVM即进行类型卸载。
我们知道老年代对象回收基本要等待并发标记结束。这意味着如果并发标记结束不及时导致堆已满但老年代空间还没完成回收就会触发Full GC所以触发并发标记的时机很重要。早期的G1调优中通常会设置下面参数但是很难给出一个普适的数值往往要根据实际运行结果调整
-XX:InitiatingHeapOccupancyPercent
在JDK 9之后的G1实现中这种调整需求会少很多因为JVM只会将该参数作为初始值会在运行时进行采样获取统计数据然后据此动态调整并发标记启动时机。对应的JVM参数如下默认已经开启
-XX:+G1UseAdaptiveIHOP
在现有的资料中大多指出G1的Full GC是最差劲的单线程串行GC。其实如果采用的是最新的JDK你会发现Full GC也是并行进行的了在通用场景中的表现还优于Parallel GC的Full GC实现。
当然还有很多其他的改变比如更快的Card Table扫描等这里不再展开介绍因为它们并不带来行为的变化基本不影响调优选择。
前面介绍了G1的内部机制并且穿插了部分调优建议下面从整体上给出一些调优的建议。
首先建议尽量升级到较新的JDK版本从上面介绍的改进就可以看到很多人们常常讨论的问题其实升级JDK就可以解决了。
第二掌握GC调优信息收集途径。掌握尽量全面、详细、准确的信息是各种调优的基础不仅仅是GC调优。我们来看看打开GC日志这似乎是很简单的事情可是你确定真的掌握了吗
除了常用的两个选项,
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
还有一些非常有用的日志选项,很多特定问题的诊断都是要依赖这些选项:
-XX:+PrintAdaptiveSizePolicy // 打印G1 Ergonomics相关信息
我们知道GC内部一些行为是适应性的触发的利用PrintAdaptiveSizePolicy我们就可以知道为什么JVM做出了一些可能我们不希望发生的动作。例如G1调优的一个基本建议就是避免进行大量的Humongous对象分配如果Ergonomics信息说明发生了这一点那么就可以考虑要么增大堆的大小要么直接将region大小提高。
如果是怀疑出现引用清理不及时的情况,则可以打开下面选项,掌握到底是哪里出现了堆积。
-XX:+PrintReferenceGC
另外,建议开启选项下面的选项进行并行引用处理。
-XX:+ParallelRefProcEnabled
需要注意的一点是JDK 9中JVM和GC日志机构进行了重构其实我前面提到的PrintGCDetails已经被标记为废弃而PrintGCDateStamps已经被移除指定它会导致JVM无法启动。可以使用下面的命令查询新的配置参数。
java -Xlog:help
最后,来看一些通用实践,理解了我前面介绍的内部结构和机制,很多结论就一目了然了,例如:
如果发现Young GC非常耗时这很可能就是因为新生代太大了我们可以考虑减小新生代的最小比例。
-XX:G1NewSizePercent
降低其最大值同样对降低Young GC延迟有帮助。
-XX:G1MaxNewSizePercent
如果我们直接为G1设置较小的延迟目标值也会起到减小新生代的效果虽然会影响吞吐量。
如果是Mixed GC延迟较长我们应该怎么做呢
还记得前面说的部分Old region会被包含进Mixed GC减少一次处理的region个数就是个直接的选择之一。-
我在上面已经介绍了G1OldCSetRegionThresholdPercent控制其最大值还可以利用下面参数提高Mixed GC的个数当前默认值是8Mixed GC数量增多意味着每次被包含的region减少。
-XX:G1MixedGCCountTarget
今天的内容算是抛砖引玉更多内容你可以参考G1调优指南等远不是几句话可以囊括的。需要注意的是也要避免过度调优G1对大堆非常友好其运行机制也需要浪费一定的空间有时候稍微多给堆一些空间比进行苛刻的调优更加实用。
今天我梳理了基本的GC调优思路并对G1内部结构以及最新的行为变化进行了详解。总的来说G1的调优相对简单、直观因为可以直接设定暂停时间等目标并且其内部引入了各种智能的自适应机制希望这一切的努力能够让你在日常应用开发时更加高效。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是定位Full GC发生的原因有哪些方式
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,156 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 Java内存模型中的happen-before是什么-极客时间
Java 语言在设计之初就引入了线程的概念,以充分利用现代处理器的计算能力,这既带来了强大、灵活的多线程机制,也带来了线程安全等令人混淆的问题,而 Java 内存模型Java Memory ModelJMM为我们提供了一个在纷乱之中达成一致的指导准则。
今天我要问你的问题是Java 内存模型中的 happen-before 是什么?
典型回答
Happen-before 关系,是 Java 内存模型中保证多线程操作可见性的机制,也是对早期语言规范中含糊的可见性概念的一个精确定义。
它的具体表现形式,包括但远不止是我们直觉中的 synchronized、volatile、lock 操作顺序等方面,例如:
线程内执行的每个操作,都保证 happen-before 后面的操作,这就保证了基本的程序顺序规则,这是开发者在书写程序时的基本约定。
对于 volatile 变量,对它的写操作,保证 happen-before 在随后对该变量的读取操作。
对于一个锁的解锁操作,保证 happen-before 加锁操作。
对象构建完成,保证 happen-before 于 finalizer 的开始动作。
甚至是类似线程内部操作的完成,保证 happen-before 其他 Thread.join() 的线程等。
这些 happen-before 关系是存在着传递性的,如果满足 a happen-before b 和 b happen-before c那么 a happen-before c 也成立。
前面我一直用 happen-before而不是简单说前后是因为它不仅仅是对执行时间的保证也包括对内存读、写操作顺序的保证。仅仅是时钟顺序上的先后并不能保证线程交互的可见性。
考点分析
今天的问题是一个常见的考察 Java 内存模型基本概念的问题,我前面给出的回答尽量选择了和日常开发相关的规则。
JMM 是面试的热点,可以看作是深入理解 Java 并发编程、编译器和 JVM 内部机制的必要条件,但这同时也是个容易让初学者无所适从的主题。对于学习 JMM我有一些个人建议
明确目的,克制住技术的诱惑。除非你是编译器或者 JVM 工程师,否则我建议不要一头扎进各种 CPU 体系结构,纠结于不同的缓存、流水线、执行单元等。这些东西虽然很酷,但其复杂性是超乎想象的,很可能会无谓增加学习难度,也未必有实践价值。
克制住对“秘籍”的诱惑。有些时候,某些编程方式看起来能起到特定效果,但分不清是实现差异导致的“表现”,还是“规范”要求的行为,就不要依赖于这种“表现”去编程,尽量遵循语言规范进行,这样我们的应用行为才能更加可靠、可预计。
在这一讲中,兼顾面试和编程实践,我会结合例子梳理下面两点:
为什么需要 JMM它试图解决什么问题
JMM 是如何解决可见性等各种问题的?类似 volatile体现在具体用例中有什么效果
注意,专栏中 Java 内存模型就是特指 JSR-133 中重新定义的 JMM 规范。在特定的上下文里,也许会与 JVMJava内存结构等混淆并不存在绝对的对错但一定要清楚面试官的本意有的面试官也会特意考察是否清楚这两种概念的区别。
知识扩展
为什么需要 JMM它试图解决什么问题
Java 是最早尝试提供内存模型的语言,这是简化多线程编程、保证程序可移植性的一个飞跃。早期类似 C、C++ 等语言并不存在内存模型的概念C++ 11 中也引入了标准内存模型),其行为依赖于处理器本身的内存一致性模型,但不同的处理器可能差异很大,所以一段 C++ 程序在处理器 A 上运行正常,并不能保证其在处理器 B 上也是一致的。
即使如此,最初的 Java 语言规范仍然是存在着缺陷的,当时的目标是,希望 Java 程序可以充分利用现代硬件的计算能力,同时保持“书写一次,到处执行”的能力。
但是,显然问题的复杂度被低估了,随着 Java 被运行在越来越多的平台上,人们发现,过于泛泛的内存模型定义,存在很多模棱两可之处,对 synchronized 或 volatile 等,类似指令重排序时的行为,并没有提供清晰规范。这里说的指令重排序,既可以是编译器优化行为,也可能是源自于现代处理器的乱序执行等。
换句话说:
既不能保证一些多线程程序的正确性例如最著名的就是双检锁Double-Checked LockingDCL的失效问题具体可以参考我在【第 14 讲】对单例模式的说明双检锁可能导致未完整初始化的对象被访问理论上这叫并发编程中的安全发布Safe Publication失败。
也不能保证同一段程序在不同的处理器架构上表现一致,例如有的处理器支持缓存一致性,有的不支持,各自都有自己的内存排序模型。
所以Java 迫切需要一个完善的 JMM能够让普通 Java 开发者和编译器、JVM 工程师,能够清晰地达成共识。换句话说,可以相对简单并准确地判断出,多线程程序什么样的执行序列是符合规范的。
所以:
对于编译器、JVM 开发者关注点可能是如何使用类似内存屏障Memory-Barrier之类技术保证执行结果符合 JMM 的推断。
对于 Java 应用开发者,则可能更加关注 volatile、synchronized 等语义,如何利用类似 happen-before 的规则写出可靠的多线程应用而不是利用一些“秘籍”去糊弄编译器、JVM。
我画了一个简单的角色层次图不同工程师分工合作其实所处的层面是有区别的。JMM 为 Java 工程师隔离了不同处理器内存排序的区别,这也是为什么我通常不建议过早深入处理器体系结构,某种意义上来说,这样本就违背了 JMM 的初衷。
JMM 是怎么解决可见性等问题的呢?
在这里,我有必要简要介绍一下典型的问题场景。
我在【第 25 讲】里介绍了 JVM 内部的运行时数据区,但是真正程序执行,实际是要跑在具体的处理器内核上。你可以简单理解为,把本地变量等数据从内存加载到缓存、寄存器,然后运算结束写回主内存。你可以从下面示意图,看这两种模型的对应。
看上去很美好,但是当多线程共享变量时,情况就复杂了。试想,如果处理器对某个共享变量进行了修改,可能只是体现在该内核的缓存里,这是个本地状态,而运行在其他内核上的线程,可能还是加载的旧状态,这很可能导致一致性的问题。从理论上来说,多线程共享引入了复杂的数据依赖性,不管编译器、处理器怎么做重排序,都必须尊重数据依赖性的要求,否则就打破了正确性!这就是 JMM 所要解决的问题。
JMM 内部的实现通常是依赖于所谓的内存屏障,通过禁止某些重排序的方式,提供内存可见性保证,也就是实现了各种 happen-before 规则。与此同时,更多复杂度在于,需要尽量确保各种编译器、各种体系结构的处理器,都能够提供一致的行为。
我以 volatile 为例,看看如何利用内存屏障实现 JMM 定义的可见性?
对于一个 volatile 变量:
对该变量的写操作之后,编译器会插入一个写屏障。
对该变量的读操作之前,编译器会插入一个读屏障。
内存屏障能够在类似变量读、写操作之后,保证其他线程对 volatile 变量的修改对当前线程可见,或者本地修改对其他线程提供可见性。换句话说,线程写入,写屏障会通过类似强迫刷出处理器缓存的方式,让其他线程能够拿到最新数值。
如果你对更多内存屏障的细节感兴趣,或者想了解不同体系结构的处理器模型,建议参考 JSR-133相关文档我个人认为这些都是和特定硬件相关的内存屏障之类只是实现 JMM 规范的技术手段,并不是规范的要求。
从应用开发者的角度JMM 提供的可见性,体现在类似 volatile 上,具体行为是什么样呢?
我这里循序渐进的举两个例子。
首先,前几天有同学问我一个问题,请看下面的代码片段,希望达到的效果是,当 condition 被赋值为 false 时,线程 A 能够从循环中退出。
// Thread A
while (condition) {
}
// Thread B
condition = false;
这里就需要 condition 被定义为 volatile 变量,不然其数值变化,往往并不能被线程 A 感知,进而无法退出。当然,也可以在 while 中,添加能够直接或间接起到类似效果的代码。
第二,我想举 Brian Goetz 提供的一个经典用例,使用 volatile 作为守卫对象,实现某种程度上轻量级的同步,请看代码片段:
Map configOptions;
char[] configText;
volatile boolean initialized = false;
// Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// Thread B
while (!initialized)
sleep();
// use configOptions
JSR-133 重新定义的 JMM 模型,能够保证线程 B 获取的 configOptions 是更新后的数值。
也就是说 volatile 变量的可见性发生了增强,能够起到守护其上下文的作用。线程 A 对 volatile 变量的赋值,会强制将该变量自己和当时其他变量的状态都刷出缓存,为线程 B 提供可见性。当然,这也是以一定的性能开销作为代价的,但毕竟带来了更加简单的多线程行为。
我们经常会说 volatile 比 synchronized 之类更加轻量但轻量也仅仅是相对的volatile 的读、写仍然要比普通的读写要开销更大,所以如果你是在性能高度敏感的场景,除非你确定需要它的语义,不然慎用。
今天,我从 happen-before 关系开始,帮你理解了什么是 Java 内存模型。为了更方便理解,我作了简化,从不同工程师的角色划分等角度,阐述了问题的由来,以及 JMM 是如何通过类似内存屏障等技术实现的。最后,我以 volatile 为例,分析了可见性在多线程场景中的典型用例。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天留给你的思考题是,给定一段代码,如何验证所有符合 JMM 执行可能?有什么工具可以辅助吗?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,164 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 Java内存模型中的happen-before是什么
Java语言在设计之初就引入了线程的概念以充分利用现代处理器的计算能力这既带来了强大、灵活的多线程机制也带来了线程安全等令人混淆的问题而Java内存模型Java Memory ModelJMM为我们提供了一个在纷乱之中达成一致的指导准则。
今天我要问你的问题是Java内存模型中的happen-before是什么
典型回答
Happen-before关系是Java内存模型中保证多线程操作可见性的机制也是对早期语言规范中含糊的可见性概念的一个精确定义。
它的具体表现形式包括但远不止是我们直觉中的synchronized、volatile、lock操作顺序等方面例如
线程内执行的每个操作都保证happen-before后面的操作这就保证了基本的程序顺序规则这是开发者在书写程序时的基本约定。
对于volatile变量对它的写操作保证happen-before在随后对该变量的读取操作。
对于一个锁的解锁操作保证happen-before加锁操作。
对象构建完成保证happen-before于finalizer的开始动作。
甚至是类似线程内部操作的完成保证happen-before其他Thread.join()的线程等。
这些happen-before关系是存在着传递性的如果满足a happen-before b和b happen-before c那么a happen-before c也成立。
前面我一直用happen-before而不是简单说前后是因为它不仅仅是对执行时间的保证也包括对内存读、写操作顺序的保证。仅仅是时钟顺序上的先后并不能保证线程交互的可见性。
考点分析
今天的问题是一个常见的考察Java内存模型基本概念的问题我前面给出的回答尽量选择了和日常开发相关的规则。
JMM是面试的热点可以看作是深入理解Java并发编程、编译器和JVM内部机制的必要条件但这同时也是个容易让初学者无所适从的主题。对于学习JMM我有一些个人建议
明确目的克制住技术的诱惑。除非你是编译器或者JVM工程师否则我建议不要一头扎进各种CPU体系结构纠结于不同的缓存、流水线、执行单元等。这些东西虽然很酷但其复杂性是超乎想象的很可能会无谓增加学习难度也未必有实践价值。
克制住对“秘籍”的诱惑。有些时候,某些编程方式看起来能起到特定效果,但分不清是实现差异导致的“表现”,还是“规范”要求的行为,就不要依赖于这种“表现”去编程,尽量遵循语言规范进行,这样我们的应用行为才能更加可靠、可预计。
在这一讲中,兼顾面试和编程实践,我会结合例子梳理下面两点:
为什么需要JMM它试图解决什么问题
JMM是如何解决可见性等各种问题的类似volatile体现在具体用例中有什么效果
注意专栏中Java内存模型就是特指JSR-133中重新定义的JMM规范。在特定的上下文里也许会与JVMJava内存结构等混淆并不存在绝对的对错但一定要清楚面试官的本意有的面试官也会特意考察是否清楚这两种概念的区别。
知识扩展
为什么需要JMM它试图解决什么问题
Java是最早尝试提供内存模型的语言这是简化多线程编程、保证程序可移植性的一个飞跃。早期类似C、C++等语言并不存在内存模型的概念C++ 11中也引入了标准内存模型其行为依赖于处理器本身的内存一致性模型但不同的处理器可能差异很大所以一段C++程序在处理器A上运行正常并不能保证其在处理器B上也是一致的。
即使如此最初的Java语言规范仍然是存在着缺陷的当时的目标是希望Java程序可以充分利用现代硬件的计算能力同时保持“书写一次到处执行”的能力。
但是显然问题的复杂度被低估了随着Java被运行在越来越多的平台上人们发现过于泛泛的内存模型定义存在很多模棱两可之处对synchronized或volatile等类似指令重排序时的行为并没有提供清晰规范。这里说的指令重排序既可以是编译器优化行为也可能是源自于现代处理器的乱序执行等。
换句话说:
既不能保证一些多线程程序的正确性例如最著名的就是双检锁Double-Checked LockingDCL的失效问题具体可以参考我在[第14讲]对单例模式的说明双检锁可能导致未完整初始化的对象被访问理论上这叫并发编程中的安全发布Safe Publication失败。
也不能保证同一段程序在不同的处理器架构上表现一致,例如有的处理器支持缓存一致性,有的不支持,各自都有自己的内存排序模型。
所以Java迫切需要一个完善的JMM能够让普通Java开发者和编译器、JVM工程师能够清晰地达成共识。换句话说可以相对简单并准确地判断出多线程程序什么样的执行序列是符合规范的。
所以:
对于编译器、JVM开发者关注点可能是如何使用类似内存屏障Memory-Barrier之类技术保证执行结果符合JMM的推断。
对于Java应用开发者则可能更加关注volatile、synchronized等语义如何利用类似happen-before的规则写出可靠的多线程应用而不是利用一些“秘籍”去糊弄编译器、JVM。
我画了一个简单的角色层次图不同工程师分工合作其实所处的层面是有区别的。JMM为Java工程师隔离了不同处理器内存排序的区别这也是为什么我通常不建议过早深入处理器体系结构某种意义上来说这样本就违背了JMM的初衷。-
JMM是怎么解决可见性等问题的呢
在这里,我有必要简要介绍一下典型的问题场景。
我在[第25讲]里介绍了JVM内部的运行时数据区但是真正程序执行实际是要跑在具体的处理器内核上。你可以简单理解为把本地变量等数据从内存加载到缓存、寄存器然后运算结束写回主内存。你可以从下面示意图看这两种模型的对应。-
看上去很美好但是当多线程共享变量时情况就复杂了。试想如果处理器对某个共享变量进行了修改可能只是体现在该内核的缓存里这是个本地状态而运行在其他内核上的线程可能还是加载的旧状态这很可能导致一致性的问题。从理论上来说多线程共享引入了复杂的数据依赖性不管编译器、处理器怎么做重排序都必须尊重数据依赖性的要求否则就打破了正确性这就是JMM所要解决的问题。
JMM内部的实现通常是依赖于所谓的内存屏障通过禁止某些重排序的方式提供内存可见性保证也就是实现了各种happen-before规则。与此同时更多复杂度在于需要尽量确保各种编译器、各种体系结构的处理器都能够提供一致的行为。
我以volatile为例看看如何利用内存屏障实现JMM定义的可见性
对于一个volatile变量
对该变量的写操作之后,编译器会插入一个写屏障。
对该变量的读操作之前,编译器会插入一个读屏障。
内存屏障能够在类似变量读、写操作之后保证其他线程对volatile变量的修改对当前线程可见或者本地修改对其他线程提供可见性。换句话说线程写入写屏障会通过类似强迫刷出处理器缓存的方式让其他线程能够拿到最新数值。
如果你对更多内存屏障的细节感兴趣或者想了解不同体系结构的处理器模型建议参考JSR-133相关文档我个人认为这些都是和特定硬件相关的内存屏障之类只是实现JMM规范的技术手段并不是规范的要求。
从应用开发者的角度JMM提供的可见性体现在类似volatile上具体行为是什么样呢
我这里循序渐进的举两个例子。
首先前几天有同学问我一个问题请看下面的代码片段希望达到的效果是当condition被赋值为false时线程A能够从循环中退出。
// Thread A
while (condition) {
}
// Thread B
condition = false;
这里就需要condition被定义为volatile变量不然其数值变化往往并不能被线程A感知进而无法退出。当然也可以在while中添加能够直接或间接起到类似效果的代码。
第二我想举Brian Goetz提供的一个经典用例使用volatile作为守卫对象实现某种程度上轻量级的同步请看代码片段
Map configOptions;
char[] configText;
volatile boolean initialized = false;
// Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// Thread B
while (!initialized)
sleep();
// use configOptions
JSR-133重新定义的JMM模型能够保证线程B获取的configOptions是更新后的数值。
也就是说volatile变量的可见性发生了增强能够起到守护其上下文的作用。线程A对volatile变量的赋值会强制将该变量自己和当时其他变量的状态都刷出缓存为线程B提供可见性。当然这也是以一定的性能开销作为代价的但毕竟带来了更加简单的多线程行为。
我们经常会说volatile比synchronized之类更加轻量但轻量也仅仅是相对的volatile的读、写仍然要比普通的读写要开销更大所以如果你是在性能高度敏感的场景除非你确定需要它的语义不然慎用。
今天我从happen-before关系开始帮你理解了什么是Java内存模型。为了更方便理解我作了简化从不同工程师的角色划分等角度阐述了问题的由来以及JMM是如何通过类似内存屏障等技术实现的。最后我以volatile为例分析了可见性在多线程场景中的典型用例。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天留给你的思考题是给定一段代码如何验证所有符合JMM执行可能有什么工具可以辅助吗
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,155 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 Java程序运行在Docker等容器环境有哪些新问题-极客时间
如今Docker 等容器早已不是新生事物正在逐步成为日常开发、部署环境的一部分。Java 能否无缝地运行在容器环境是否符合微服务、Serverless 等新的软件架构和场景在一定程度上也会影响未来的技术栈选择。当然Java 对 Docker 等容器环境的支持也在不断增强自然地Java 在容器场景的实践也逐渐在面试中被涉及。我希望通过专栏今天这一讲,能够帮你能做到胸有成竹。
今天我要问你的问题是Java 程序运行在 Docker 等容器环境有哪些新问题?
典型回答
对于 Java 来说Docker 毕竟是一个较新的环境例如其内存、CPU 等资源限制是通过 CGroupControl Group实现的早期的 JDK 版本8u131 之前)并不能识别这些限制,进而会导致一些基础问题:
如果未配置合适的 JVM 堆和元数据区、直接内存等参数Java 就有可能试图使用超过容器限制的内存,最终被容器 OOM kill或者自身发生 OOM。
错误判断了可获取的 CPU 资源例如Docker 限制了 CPU 的核数JVM 就可能设置不合适的 GC 并行线程数等。
从应用打包、发布等角度出发JDK 自身就比较大,生成的镜像就更为臃肿,当我们的镜像非常多的时候,镜像的存储等开销就比较明显了。
如果考虑到微服务、Serverless 等新的架构和场景Java 自身的大小、内存占用、启动速度,都存在一定局限性,因为 Java 早期的优化大多是针对长时间运行的大型服务器端应用。
考点分析
今天的问题是个针对特定场景和知识点的问题,我给出的回答简单总结了目前业界实践中发现的一些问题。
如果我是面试官,针对这种问题,如果你确实没有太多 Java 在 Docker 环境的使用经验,直接说不知道,也算是可以接受的,毕竟没有人能够掌握所有知识点嘛。
但我们要清楚有经验的面试官一般不会以纯粹偏僻的知识点作为面试考察的目的更多是考察思考问题的思路和解决问题的方法。所以如果有基础的话可以从操作系统、容器原理、JVM 内部机制、软件开发实践等角度,展示系统性分析新问题、新场景的能力。毕竟,变化才是世界永远的主题,能够在新变化中找出共性与关键,是优秀工程师的必备能力。
今天我会围绕下面几个方面展开:
面试官可能会进一步问到,有没有想过为什么类似 Docker 这种容器环境会有点“欺负”Java从 JVM 内部机制来说,问题出现在哪里?
我注意到有种论调说“没人在容器环境用 Java”不去争论这个观点正确与否我会从工程实践出发梳理问题原因和相关解决方案并探讨下新场景下的最佳实践。
知识扩展
首先,我们先来搞清楚 Java 在容器环境的局限性来源Docker 到底有什么特别?
虽然看起来 Docker 之类容器和虚拟机非常相似,例如,它也有自己的 shell能独立安装软件包运行时与其他容器互不干扰。但是如果深入分析你会发现Docker 并不是一种完全的虚拟化技术,而更是一种轻量级的隔离技术。
上面的示意图,展示了 Docker 与虚拟机的区别。从技术角度,基于 namespaceDocker 为每个容器提供了单独的命名空间对网络、PID、用户、IPC 通信、文件系统挂载点等实现了隔离。对于 CPU、内存、磁盘 IO 等计算资源,则是通过 CGroup 进行管理。如果你想了解更多 Docker 的细节,请参考相关技术文档。
Docker 仅在类似 Linux 内核之上实现了有限的隔离和虚拟化,并不是像传统虚拟化软件那样,独立运行一个新的操作系统。如果是虚拟化的操作系统,不管是 Java 还是其他程序,只要调用的是同一个系统 API都可以透明地获取所需的信息基本不需要额外的兼容性改变。
容器虽然省略了虚拟操作系统的开销,实现了轻量级的目标,但也带来了额外复杂性,它限制对于应用不是透明的,需要用户理解 Docker 的新行为。所以,有专家曾经说过,“幸运的是 Docker 没有完全隐藏底层信息,但是不幸的也是 Docker 没有隐藏底层信息!”
对于 Java 平台来说,这些未隐藏的底层信息带来了很多意外的困难,主要体现在几个方面:
第一容器环境对于计算资源的管理方式是全新的CGroup 作为相对比较新的技术,历史版本的 Java 显然并不能自然地理解相应的资源限制。
第二namespace 对于容器内的应用细节增加了一些微妙的差异,比如 jcmd、jstack 等工具会依赖于“/proc//”下面提供的部分信息,但是 Docker 的设计改变了这部分信息的原有结构,我们需要对原有工具进行修改以适应这种变化。
从 JVM 运行机制的角度,为什么这些“沟通障碍”会导致 OOM 等问题呢?
你可以思考一下,这个问题实际是反映了 JVM 如何根据系统资源内存、CPU 等)情况,在启动时设置默认参数。
这就是所谓的Ergonomics机制例如
JVM 会大概根据检测到的内存大小,设置最初启动时的堆大小为系统内存的 1/64并将堆最大值设置为系统内存的 1/4。
而 JVM 检测到系统的 CPU 核数,则直接影响到了 Parallel GC 的并行线程数目和 JIT complier 线程数目,甚至是我们应用中 ForkJoinPool 等机制的并行等级。
这些默认参数是根据通用场景选择的初始值。但是由于容器环境的差异Java 的判断很可能是基于错误信息而做出的。这就类似,我以为我住的是整栋别墅,实际上却只有一个房间是给我住的。
更加严重的是JVM 的一些原有诊断或备用机制也会受到影响。为保证服务的可用性,一种常见的选择是依赖“-XX:OnOutOfMemoryError”功能通过调用处理脚本的形式来做一些补救措施比如自动重启服务等。但是这种机制是基于 fork 实现的,当 Java 进程已经过度提交内存时fork 新的进程往往已经不可能正常运行了。
根据前面的总结,似乎问题非常棘手,那我们在实践中,如何解决这些问题呢?
首先,如果你能够升级到最新的 JDK 版本,这个问题就迎刃而解了。
针对这种情况JDK 9 中引入了一些实验性的参数,以方便 Docker 和 Java“沟通”例如针对内存限制可以使用下面的参数设置
-XX:+UnlockExperimentalVMOptions
-XX:+UseCGroupMemoryLimitForHeap
注意,这两个参数是顺序敏感的,并且只支持 Linux 环境。而对于 CPU 核心数限定Java 已经被修正为可以正确理解“cpuset-cpus”等设置无需单独设置参数。
如果你可以切换到 JDK 10 或者更新的版本问题就更加简单了。Java 对容器Docker的支持已经比较完善默认就会自适应各种资源限制和实现差异。前面提到的实验性参数“UseCGroupMemoryLimitForHeap”已经被标记为废弃。
与此同时,新增了参数用以明确指定 CPU 核心的数目。
-XX:ActiveProcessorCount=N
如果实践中发现有问题,也可以使用“-XX:-UseContainerSupport”关闭 Java 的容器支持特性,这可以作为一种防御性机制,避免新特性破坏原有基础功能。当然,也欢迎你向 OpenJDK 社区反馈问题。
幸运的是JDK 9 中的实验性改进已经被移植到 Oracle JDK 8u131 之中你可以直接下载相应镜像并配置“UseCGroupMemoryLimitForHeap”后续很有可能还会进一步将 JDK 10 中相关的增强,应用到 JDK 8 最新的更新中。
但是,如果我暂时只能使用老版本的 JDK 怎么办?
我这里有几个建议:
明确设置堆、元数据区等内存区域大小,保证 Java 进程的总大小可控。
例如,我们可能在环境中,这样限制容器内存:
$ docker run -it --rm --name yourcontainer -p 8080:8080 -m 800M repo/your-java-container:openjdk
那么,就可以额外配置下面的环境变量,直接指定 JVM 堆大小。
-e JAVA_OPTIONS='-Xmx300m'
明确配置 GC 和 JIT 并行线程数目,以避免二者占用过多计算资源。
-XX:ParallelGCThreads
-XX:CICompilerCount
除了我前面介绍的 OOM 等问题,在很多场景中还发现 Java 在 Docker 环境中,似乎会意外使用 Swap。具体原因待查但很有可能也是因为 Ergonomics 机制失效导致的,我建议配置下面参数,明确告知 JVM 系统内存限额。
-XX:MaxRAM=`cat /sys/fs/cgroup/memory/memory.limit_in_bytes`
也可以指定 Docker 运行参数,例如:
--memory-swappiness=0
这是受操作系统Swappiness机制影响当内存消耗达到一定门限操作系统会试图将不活跃的进程换出Swap out上面的参数有显式关闭 Swap 的作用。所以可以看到Java 在 Docker 中的使用,从操作系统、内核到 JVM 自身机制,需要综合运用我们所掌握的知识。
回顾我在专栏第 25 讲 JVM 内存区域的介绍JVM 内存消耗远不止包括堆,很多时候仅仅设置 Xmx 是不够的MaxRAM 也有助于 JVM 合理分配其他内存区域。如果应用需要设置更多 Java 启动参数,但又不确定什么数值合理,可以试试一些社区提供的工具,但要注意通用工具的局限性。
更进一步来说,对于容器镜像大小的问题,如果你使用的是 JDK 9 以后的版本,完全可以使用 jlink 工具定制最小依赖的 Java 运行环境,将 JDK 裁剪为几十 M 的大小,这样运行起来并不困难。
今天我从 Docker 环境中 Java 可能出现的问题开始,分析了为什么容器环境对应用并不透明,以及这种偏差干扰了 JVM 的相关机制。最后,我从实践出发,介绍了主要问题的解决思路,希望对你在实际开发时有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,针对我提到的微服务和 Serverless 等场景 Java 表现出的不足,有哪些方法可以改善 Java 的表现?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,159 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 Java程序运行在Docker等容器环境有哪些新问题
如今Docker等容器早已不是新生事物正在逐步成为日常开发、部署环境的一部分。Java能否无缝地运行在容器环境是否符合微服务、Serverless等新的软件架构和场景在一定程度上也会影响未来的技术栈选择。当然Java对Docker等容器环境的支持也在不断增强自然地Java在容器场景的实践也逐渐在面试中被涉及。我希望通过专栏今天这一讲能够帮你能做到胸有成竹。
今天我要问你的问题是Java程序运行在Docker等容器环境有哪些新问题
典型回答
对于Java来说Docker毕竟是一个较新的环境例如其内存、CPU等资源限制是通过CGroupControl Group实现的早期的JDK版本8u131之前并不能识别这些限制进而会导致一些基础问题
如果未配置合适的JVM堆和元数据区、直接内存等参数Java就有可能试图使用超过容器限制的内存最终被容器OOM kill或者自身发生OOM。
错误判断了可获取的CPU资源例如Docker限制了CPU的核数JVM就可能设置不合适的GC并行线程数等。
从应用打包、发布等角度出发JDK自身就比较大生成的镜像就更为臃肿当我们的镜像非常多的时候镜像的存储等开销就比较明显了。
如果考虑到微服务、Serverless等新的架构和场景Java自身的大小、内存占用、启动速度都存在一定局限性因为Java早期的优化大多是针对长时间运行的大型服务器端应用。
考点分析
今天的问题是个针对特定场景和知识点的问题,我给出的回答简单总结了目前业界实践中发现的一些问题。
如果我是面试官针对这种问题如果你确实没有太多Java在Docker环境的使用经验直接说不知道也算是可以接受的毕竟没有人能够掌握所有知识点嘛。
但我们要清楚有经验的面试官一般不会以纯粹偏僻的知识点作为面试考察的目的更多是考察思考问题的思路和解决问题的方法。所以如果有基础的话可以从操作系统、容器原理、JVM内部机制、软件开发实践等角度展示系统性分析新问题、新场景的能力。毕竟变化才是世界永远的主题能够在新变化中找出共性与关键是优秀工程师的必备能力。
今天我会围绕下面几个方面展开:
面试官可能会进一步问到有没有想过为什么类似Docker这种容器环境会有点“欺负”Java从JVM内部机制来说问题出现在哪里
我注意到有种论调说“没人在容器环境用Java”不去争论这个观点正确与否我会从工程实践出发梳理问题原因和相关解决方案并探讨下新场景下的最佳实践。
知识扩展
首先我们先来搞清楚Java在容器环境的局限性来源Docker到底有什么特别
虽然看起来Docker之类容器和虚拟机非常相似例如它也有自己的shell能独立安装软件包运行时与其他容器互不干扰。但是如果深入分析你会发现Docker并不是一种完全的虚拟化技术而更是一种轻量级的隔离技术。-
上面的示意图展示了Docker与虚拟机的区别。从技术角度基于namespaceDocker为每个容器提供了单独的命名空间对网络、PID、用户、IPC通信、文件系统挂载点等实现了隔离。对于CPU、内存、磁盘IO等计算资源则是通过CGroup进行管理。如果你想了解更多Docker的细节请参考相关技术文档。
Docker仅在类似Linux内核之上实现了有限的隔离和虚拟化并不是像传统虚拟化软件那样独立运行一个新的操作系统。如果是虚拟化的操作系统不管是Java还是其他程序只要调用的是同一个系统API都可以透明地获取所需的信息基本不需要额外的兼容性改变。
容器虽然省略了虚拟操作系统的开销实现了轻量级的目标但也带来了额外复杂性它限制对于应用不是透明的需要用户理解Docker的新行为。所以有专家曾经说过“幸运的是Docker没有完全隐藏底层信息但是不幸的也是Docker没有隐藏底层信息
对于Java平台来说这些未隐藏的底层信息带来了很多意外的困难主要体现在几个方面
第一容器环境对于计算资源的管理方式是全新的CGroup作为相对比较新的技术历史版本的Java显然并不能自然地理解相应的资源限制。
第二namespace对于容器内的应用细节增加了一些微妙的差异比如jcmd、jstack等工具会依赖于“/proc//”下面提供的部分信息但是Docker的设计改变了这部分信息的原有结构我们需要对原有工具进行修改以适应这种变化。
从JVM运行机制的角度为什么这些“沟通障碍”会导致OOM等问题呢
你可以思考一下这个问题实际是反映了JVM如何根据系统资源内存、CPU等情况在启动时设置默认参数。
这就是所谓的Ergonomics机制例如
JVM会大概根据检测到的内存大小设置最初启动时的堆大小为系统内存的1/64并将堆最大值设置为系统内存的1/4。
而JVM检测到系统的CPU核数则直接影响到了Parallel GC的并行线程数目和JIT complier线程数目甚至是我们应用中ForkJoinPool等机制的并行等级。
这些默认参数是根据通用场景选择的初始值。但是由于容器环境的差异Java的判断很可能是基于错误信息而做出的。这就类似我以为我住的是整栋别墅实际上却只有一个房间是给我住的。
更加严重的是JVM的一些原有诊断或备用机制也会受到影响。为保证服务的可用性一种常见的选择是依赖“-XX:OnOutOfMemoryError”功能通过调用处理脚本的形式来做一些补救措施比如自动重启服务等。但是这种机制是基于fork实现的当Java进程已经过度提交内存时fork新的进程往往已经不可能正常运行了。
根据前面的总结,似乎问题非常棘手,那我们在实践中,如何解决这些问题呢?
首先如果你能够升级到最新的JDK版本这个问题就迎刃而解了。
针对这种情况JDK 9中引入了一些实验性的参数以方便Docker和Java“沟通”例如针对内存限制可以使用下面的参数设置
-XX:+UnlockExperimentalVMOptions
-XX:+UseCGroupMemoryLimitForHeap
注意这两个参数是顺序敏感的并且只支持Linux环境。而对于CPU核心数限定Java已经被修正为可以正确理解“cpuset-cpus”等设置无需单独设置参数。
如果你可以切换到JDK 10或者更新的版本问题就更加简单了。Java对容器Docker的支持已经比较完善默认就会自适应各种资源限制和实现差异。前面提到的实验性参数“UseCGroupMemoryLimitForHeap”已经被标记为废弃。
与此同时新增了参数用以明确指定CPU核心的数目。
-XX:ActiveProcessorCount=N
如果实践中发现有问题,也可以使用“-XX:-UseContainerSupport”关闭Java的容器支持特性这可以作为一种防御性机制避免新特性破坏原有基础功能。当然也欢迎你向OpenJDK社区反馈问题。
幸运的是JDK 9中的实验性改进已经被移植到Oracle JDK 8u131之中你可以直接下载相应镜像并配置“UseCGroupMemoryLimitForHeap”后续很有可能还会进一步将JDK 10中相关的增强应用到JDK 8最新的更新中。
但是如果我暂时只能使用老版本的JDK怎么办
我这里有几个建议:
明确设置堆、元数据区等内存区域大小保证Java进程的总大小可控。
例如,我们可能在环境中,这样限制容器内存:
$ docker run -it --rm --name yourcontainer -p 8080:8080 -m 800M repo/your-java-container:openjdk
那么就可以额外配置下面的环境变量直接指定JVM堆大小。
-e JAVA_OPTIONS='-Xmx300m'
明确配置GC和JIT并行线程数目以避免二者占用过多计算资源。
-XX:ParallelGCThreads
-XX:CICompilerCount
除了我前面介绍的OOM等问题在很多场景中还发现Java在Docker环境中似乎会意外使用Swap。具体原因待查但很有可能也是因为Ergonomics机制失效导致的我建议配置下面参数明确告知JVM系统内存限额。
-XX:MaxRAM=`cat /sys/fs/cgroup/memory/memory.limit_in_bytes`
也可以指定Docker运行参数例如
--memory-swappiness=0
这是受操作系统Swappiness机制影响当内存消耗达到一定门限操作系统会试图将不活跃的进程换出Swap out上面的参数有显式关闭Swap的作用。所以可以看到Java在Docker中的使用从操作系统、内核到JVM自身机制需要综合运用我们所掌握的知识。
回顾我在专栏第25讲JVM内存区域的介绍JVM内存消耗远不止包括堆很多时候仅仅设置Xmx是不够的MaxRAM也有助于JVM合理分配其他内存区域。如果应用需要设置更多Java启动参数但又不确定什么数值合理可以试试一些社区提供的工具但要注意通用工具的局限性。
更进一步来说对于容器镜像大小的问题如果你使用的是JDK 9以后的版本完全可以使用jlink工具定制最小依赖的Java运行环境将JDK裁剪为几十M的大小这样运行起来并不困难。
今天我从Docker环境中Java可能出现的问题开始分析了为什么容器环境对应用并不透明以及这种偏差干扰了JVM的相关机制。最后我从实践出发介绍了主要问题的解决思路希望对你在实际开发时有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是针对我提到的微服务和Serverless等场景Java表现出的不足有哪些方法可以改善Java的表现
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,136 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 你了解Java应用开发中的注入攻击吗-极客时间
安全是软件开发领域永远的主题之一,随着新技术浪潮的兴起,安全的重要性愈发凸显出来,对于金融等行业,甚至可以说安全是企业的生命线。不论是移动设备、普通 PC、小型机还是大规模分布式系统以及各种主流操作系统Java 作为软件开发的基础平台之一,可以说是无处不在,自然也就成为安全攻击的首要目标之一。
今天我要问你的问题是,你了解 Java 应用开发中的注入攻击吗?
典型回答
注入式Inject攻击是一类非常常见的攻击方式其基本特征是程序允许攻击者将不可信的动态内容注入到程序中并将其执行这就可能完全改变最初预计的执行过程产生恶意效果。
下面是几种主要的注入式攻击途径,原则上提供动态执行能力的语言特性,都需要提防发生注入攻击的可能。
首先,就是最常见的 SQL 注入攻击。一个典型的场景就是 Web 系统的用户登录功能,根据用户输入的用户名和密码,我们需要去后端数据库核实信息。
假设应用逻辑是,后端程序利用界面输入动态生成类似下面的 SQL然后让 JDBC 执行。
select * from use_info where username = “input_usr_name” and password = “input_pwd”
但是,如果我输入的 input_pwd 是类似下面的文本,
“ or “”=”
那么,拼接出的 SQL 字符串就变成了下面的条件OR 的存在导致输入什么名字都是复合条件的。
select * from use_info where username = “input_usr_name” and password = “” or “” = “”
这里只是举个简单的例子,它是利用了期望输入和可能输入之间的偏差。上面例子中,期望用户输入一个数值,但实际输入的则是 SQL 语句片段。类似场景可以利用注入的不同 SQL 语句,进行各种不同目的的攻击,甚至还可以加上“;delete xxx”之类语句如果数据库权限控制不合理攻击效果就可能是灾难性的。
第二操作系统命令注入。Java 语言提供了类似 Runtime.exec(…) 的 API可以用来执行特定命令假设我们构建了一个应用以输入文本作为参数执行下面的命令
ls la input_file_name
但是如果用户输入是 “input_file_name;rm rf /*”这就有可能出现问题了。当然这只是个举例Java 标准类库本身进行了非常多的改进,所以类似这种编程错误,未必可以真的完成攻击,但其反映的一类场景是真实存在的。
第三XML 注入攻击。Java 核心类库提供了全面的 XML 处理、转换等各种 API而 XML 自身是可以包含动态内容的,例如 XPATH如果使用不当可能导致访问恶意内容。
还有类似 LDAP 等允许动态内容的协议,都是可能利用特定命令,构造注入式攻击的,包括 XSSCross-site Scripting攻击虽然并不和 Java 直接相关,但也可能在 JSP 等动态页面中发生。
考点分析
今天的问题是安全领域的入门题目,我简单介绍了最常见的几种注入场景作为示例。安全本身是个非常大的主题,在面试中,面试官可能会考察安全问题,但如果不是特定安全专家岗位,了解基础的安全实践就可以满足要求了。
Java 工程师未必都要成为安全专家,但了解基础的安全领域常识,有利于发现和规避日常开发中的风险。今天我会侧重和 Java 开发相关的安全内容,希望可以起到一个抛砖引玉的作用,让你对 Java 开发安全领域有个整体印象。
谈到 Java 应用安全,主要涉及哪些安全机制?
到底什么是安全漏洞?对于前面提到的 SQL 注入等典型攻击,我们在开发中怎么避免?
知识扩展
首先,一起来看看哪些 Java API 和工具构成了 Java 安全基础。很多方面我在专栏前面的讲解中已经有所涉及,可以简单归为三个主要组成部分:
第一,运行时安全机制。可以简单认为,就是限制 Java 运行时的行为,不要做越权或者不靠谱的事情,具体来看:
在类加载过程中,进行字节码验证,以防止不合规的代码影响 JVM 运行或者载入其他恶意代码。
类加载器本身也可以对代码之间进行隔离例如应用无法获取启动类加载器Bootstrap Class-Loader对象实例不同的类加载器也可以起到容器的作用隔离模块之间不必要的可见性等。目前Java Applet、RMI 等特性已经或逐渐退出历史舞台,类加载等机制总体上反倒在不断简化。
利用 SecurityManger 机制和相关的组件,限制代码的运行时行为能力,其中,你可以定制 policy 文件和各种粒度的权限定义,限制代码的作用域和权限,例如对文件系统的操作权限,或者监听某个网络端口的权限等。我画了一个简单的示意图,对运行时安全的不同层次进行了整理。
可以看到Java 的安全模型是以代码为中心的,贯穿了从类加载,如 URLClassLoader 加载网络上的 Java 类等,到应用程序运行时权限检查等全过程。
另外从原则上来说Java 的 GC 等资源回收管理机制,都可以看作是运行时安全的一部分,如果相应机制失效,就会导致 JVM 出现 OOM 等错误,可看作是另类的拒绝服务。
第二Java 提供的安全框架 API这是构建安全通信等应用的基础。例如
加密、解密 API。
授权、鉴权 API。
安全通信相关的类库,比如基本 HTTPS 通信协议相关标准实现如TLS 1.3或者附属的类似证书撤销状态判断OSCP等协议实现。
注意,这一部分 API 内部实现是和厂商相关的,不同 JDK 厂商往往会定制自己的加密算法实现。
第三, 就是 JDK 集成的各种安全工具,例如:
keytool这是个强大的工具可以管理安全场景中不可或缺的秘钥、证书等并且可以管理 Java 程序使用的 keystore 文件。
jarsigner用于对 jar 文件进行签名或者验证。
在应用实践中,如果对安全要求非常高,建议打开 SecurityManager
-Djava.security.manager
请注意其开销,通常只要开启 SecurityManager就会导致 10% ~ 15% 的性能下降,在 JDK 9 以后,这个开销有所改善。
理解了基础 Java 安全机制接下来我们来一起探讨安全漏洞Vulnerability
按照传统的定义,任何可以用来绕过系统安全策略限制的程序瑕疵,都可以算作安全漏洞。具体原因可能非常多,设计或实现中的疏漏、配置错误等,任何不慎都有可能导致安全漏洞出现,例如恶意代码绕过了 Java 沙箱的限制获取了特权等。如果你想了解更多安全漏洞的信息可以从通用安全漏洞库CVE等途径获取了解安全漏洞评价标准。
但是要达到攻击的目的未必都需要绕过权限限制。比如利用哈希碰撞发起拒绝服务攻击DOSDenial-Of-Service attack常见的场景是攻击者可以事先构造大量相同哈希值的数据然后以 JSON 数据的形式发送给服务器端,服务器端在将其构建成为 Java 对象过程中,通常以 Hastable 或 HashMap 等形式存储哈希碰撞将导致哈希表发生严重退化算法复杂度可能上升一个数量级HashMap 后续进行了改进,我在【专栏第 9 讲】介绍了树化机制),进而耗费大量 CPU 资源。
像这种攻击方式,无关于权限,可以看作是程序实现的瑕疵,给了攻击者以低成本进行进攻的机会。
我在开头提到的各种注入式攻击,可以有不同角度、不同层面的解决方法,例如针对 SQL 注入:
在数据输入阶段,填补期望输入和可能输入之间的鸿沟。可以进行输入校验,限定什么类型的输入是合法的,例如,不允许输入标点符号等特殊字符,或者特定结构的输入。
在 Java 应用进行数据库访问时,如果不用完全动态的 SQL而是利用 PreparedStatement可以有效防范 SQL 注入。不管是 SQL 注入,还是 OS 命令注入,程序利用字符串拼接生成运行逻辑都是个可能的风险点!
在数据库层面,如果对查询、修改等权限进行了合理限制,就可以在一定程度上避免被注入删除等高破坏性的代码。
在安全领域,有一句准则:安全倾向于 “明显没有漏洞”,而不是“没有明显漏洞”。所以,为了更加安全可靠的服务,我们最好是采取整体性的安全设计和综合性的防范手段,而不是头痛医头、脚痛医脚的修修补补,更不能心存侥幸。
一个比较普适的建议是,尽量使用较新版本的 JDK并使用推荐的安全机制和标准。如果你有看过 JDK release notes例如8u141你会发现 JDK 更新会修复已知的安全漏洞,并且会对安全机制等进行增强。但现实情况是,相当一部分应用还在使用很古老的不安全版本 JDK 进行开发,并且很多信息处理的也很随意,或者通过明文传输、存储,这些都存在暴露安全隐患的可能。
今天我首先介绍了典型的注入攻击,然后整理了 Java 内部的安全机制,并探讨了到底什么是安全漏洞和典型的表现形式,以及如何防范 SQL 注入攻击等,希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,你知道 Man-In-The-MiddleMITM攻击吗有哪些常见的表现形式如何防范呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
7 月 19 日也就是本周四晚上 8 点半,我会做客极客 Live做一期主题为“1 小时搞定 Java 面试”的直播分享,我会聊聊 Java 面试那些事儿,感兴趣的同学不要错过哦。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,144 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 你了解Java应用开发中的注入攻击吗
安全是软件开发领域永远的主题之一随着新技术浪潮的兴起安全的重要性愈发凸显出来对于金融等行业甚至可以说安全是企业的生命线。不论是移动设备、普通PC、小型机还是大规模分布式系统以及各种主流操作系统Java作为软件开发的基础平台之一可以说是无处不在自然也就成为安全攻击的首要目标之一。
今天我要问你的问题是你了解Java应用开发中的注入攻击吗
典型回答
注入式Inject攻击是一类非常常见的攻击方式其基本特征是程序允许攻击者将不可信的动态内容注入到程序中并将其执行这就可能完全改变最初预计的执行过程产生恶意效果。
下面是几种主要的注入式攻击途径,原则上提供动态执行能力的语言特性,都需要提防发生注入攻击的可能。
首先就是最常见的SQL注入攻击。一个典型的场景就是Web系统的用户登录功能根据用户输入的用户名和密码我们需要去后端数据库核实信息。
假设应用逻辑是后端程序利用界面输入动态生成类似下面的SQL然后让JDBC执行。
Select * from use_info where username = “input_usr_name” and password = “input_pwd”
但是如果我输入的input_pwd是类似下面的文本
“ or “”=”
那么拼接出的SQL字符串就变成了下面的条件OR的存在导致输入什么名字都是复合条件的。
Select * from use_info where username = “input_usr_name” and password = “” or “” = “”
这里只是举个简单的例子它是利用了期望输入和可能输入之间的偏差。上面例子中期望用户输入一个数值但实际输入的则是SQL语句片段。类似场景可以利用注入的不同SQL语句进行各种不同目的的攻击甚至还可以加上“;delete xxx”之类语句如果数据库权限控制不合理攻击效果就可能是灾难性的。
第二操作系统命令注入。Java语言提供了类似Runtime.exec(…)的API可以用来执行特定命令假设我们构建了一个应用以输入文本作为参数执行下面的命令
ls la input_file_name
但是如果用户输入是 “input_file_name;rm rf /*”这就有可能出现问题了。当然这只是个举例Java标准类库本身进行了非常多的改进所以类似这种编程错误未必可以真的完成攻击但其反映的一类场景是真实存在的。
第三XML注入攻击。Java核心类库提供了全面的XML处理、转换等各种API而XML自身是可以包含动态内容的例如XPATH如果使用不当可能导致访问恶意内容。
还有类似LDAP等允许动态内容的协议都是可能利用特定命令构造注入式攻击的包括XSSCross-site Scripting攻击虽然并不和Java直接相关但也可能在JSP等动态页面中发生。
考点分析
今天的问题是安全领域的入门题目,我简单介绍了最常见的几种注入场景作为示例。安全本身是个非常大的主题,在面试中,面试官可能会考察安全问题,但如果不是特定安全专家岗位,了解基础的安全实践就可以满足要求了。
Java工程师未必都要成为安全专家但了解基础的安全领域常识有利于发现和规避日常开发中的风险。今天我会侧重和Java开发相关的安全内容希望可以起到一个抛砖引玉的作用让你对Java开发安全领域有个整体印象。
谈到Java应用安全主要涉及哪些安全机制
到底什么是安全漏洞对于前面提到的SQL注入等典型攻击我们在开发中怎么避免
知识扩展
首先一起来看看哪些Java API和工具构成了Java安全基础。很多方面我在专栏前面的讲解中已经有所涉及可以简单归为三个主要组成部分
第一运行时安全机制。可以简单认为就是限制Java运行时的行为不要做越权或者不靠谱的事情具体来看
在类加载过程中进行字节码验证以防止不合规的代码影响JVM运行或者载入其他恶意代码。
类加载器本身也可以对代码之间进行隔离例如应用无法获取启动类加载器Bootstrap Class-Loader对象实例不同的类加载器也可以起到容器的作用隔离模块之间不必要的可见性等。目前Java Applet、RMI等特性已经或逐渐退出历史舞台类加载等机制总体上反倒在不断简化。
利用SecurityManger机制和相关的组件限制代码的运行时行为能力其中你可以定制policy文件和各种粒度的权限定义限制代码的作用域和权限例如对文件系统的操作权限或者监听某个网络端口的权限等。我画了一个简单的示意图对运行时安全的不同层次进行了整理。
可以看到Java的安全模型是以代码为中心的贯穿了从类加载如URLClassLoader加载网络上的Java类等到应用程序运行时权限检查等全过程。
另外从原则上来说Java的GC等资源回收管理机制都可以看作是运行时安全的一部分如果相应机制失效就会导致JVM出现OOM等错误可看作是另类的拒绝服务。
第二Java提供的安全框架API这是构建安全通信等应用的基础。例如
加密、解密API。
授权、鉴权API。
安全通信相关的类库比如基本HTTPS通信协议相关标准实现如TLS 1.3或者附属的类似证书撤销状态判断OSCP等协议实现。
注意这一部分API内部实现是和厂商相关的不同JDK厂商往往会定制自己的加密算法实现。
第三, 就是JDK集成的各种安全工具例如
keytool这是个强大的工具可以管理安全场景中不可或缺的秘钥、证书等并且可以管理Java程序使用的keystore文件。
jarsigner用于对jar文件进行签名或者验证。
在应用实践中如果对安全要求非常高建议打开SecurityManager
-Djava.security.manager
请注意其开销通常只要开启SecurityManager就会导致10% ~ 15%的性能下降在JDK 9以后这个开销有所改善。
理解了基础Java安全机制接下来我们来一起探讨安全漏洞Vulnerability
按照传统的定义任何可以用来绕过系统安全策略限制的程序瑕疵都可以算作安全漏洞。具体原因可能非常多设计或实现中的疏漏、配置错误等任何不慎都有可能导致安全漏洞出现例如恶意代码绕过了Java沙箱的限制获取了特权等。如果你想了解更多安全漏洞的信息可以从通用安全漏洞库CVE等途径获取了解安全漏洞评价标准。
但是要达到攻击的目的未必都需要绕过权限限制。比如利用哈希碰撞发起拒绝服务攻击DOSDenial-Of-Service attack常见的场景是攻击者可以事先构造大量相同哈希值的数据然后以JSON数据的形式发送给服务器端服务器端在将其构建成为Java对象过程中通常以Hastable或HashMap等形式存储哈希碰撞将导致哈希表发生严重退化算法复杂度可能上升一个数量级HashMap后续进行了改进我在[专栏第9讲]介绍了树化机制进而耗费大量CPU资源。
像这种攻击方式,无关于权限,可以看作是程序实现的瑕疵,给了攻击者以低成本进行进攻的机会。
我在开头提到的各种注入式攻击可以有不同角度、不同层面的解决方法例如针对SQL注入
在数据输入阶段,填补期望输入和可能输入之间的鸿沟。可以进行输入校验,限定什么类型的输入是合法的,例如,不允许输入标点符号等特殊字符,或者特定结构的输入。
在Java应用进行数据库访问时如果不用完全动态的SQL而是利用PreparedStatement可以有效防范SQL注入。不管是SQL注入还是OS命令注入程序利用字符串拼接生成运行逻辑都是个可能的风险点
在数据库层面,如果对查询、修改等权限进行了合理限制,就可以在一定程度上避免被注入删除等高破坏性的代码。
在安全领域,有一句准则:安全倾向于 “明显没有漏洞”,而不是“没有明显漏洞”。所以,为了更加安全可靠的服务,我们最好是采取整体性的安全设计和综合性的防范手段,而不是头痛医头、脚痛医脚的修修补补,更不能心存侥幸。
一个比较普适的建议是尽量使用较新版本的JDK并使用推荐的安全机制和标准。如果你有看过JDK release notes例如8u141你会发现JDK更新会修复已知的安全漏洞并且会对安全机制等进行增强。但现实情况是相当一部分应用还在使用很古老的不安全版本JDK进行开发并且很多信息处理的也很随意或者通过明文传输、存储这些都存在暴露安全隐患的可能。
今天我首先介绍了典型的注入攻击然后整理了Java内部的安全机制并探讨了到底什么是安全漏洞和典型的表现形式以及如何防范SQL注入攻击等希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是你知道Man-In-The-MiddleMITM攻击吗有哪些常见的表现形式如何防范呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
7月19日也就是本周四晚上8点半我会做客极客Live做一期主题为“1小时搞定Java面试”的直播分享我会聊聊Java面试那些事儿感兴趣的同学不要错过哦。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,134 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 如何写出安全的Java代码-极客时间
在上一讲中,我们已经初步接触了 Java 安全,今天我们将一起探讨更多 Java 开发中可能影响到安全的场合。很多安全问题,在特定的上下文,存在着不同的定义,尽管本质是相似或一致的,这是由于 Java 平台自身的特性所带来特有的问题。今天这一讲我将侧重于 Java 开发者的角度谈代码安全,而不是讲广义的安全风险。
今天我要问你的问题是,如何写出安全的 Java 代码?
典型回答
这个问题可能有点宽泛我们可以用特定类型的安全风险为例如拒绝服务DoS攻击分析 Java 开发者需要重点考虑的点。
DoS 是一种常见的网络攻击,有人也称其为“洪水攻击”。最常见的表现是,利用大量机器发送请求,将目标网站的带宽或者其他资源耗尽,导致其无法响应正常用户的请求。
我认为,从 Java 语言的角度,更加需要重视的是程序级别的攻击,也就是利用 Java、JVM 或应用程序的瑕疵,进行低成本的 DoS 攻击,这也是想要写出安全的 Java 代码所必须考虑的。例如:
如果使用的是早期的 JDK 和 Applet 等技术,攻击者构建合法但恶劣的程序就相对容易,例如,将其线程优先级设置为最高,做一些看起来无害但空耗资源的事情。幸运的是类似技术已经逐步退出历史舞台,在 JDK 9 以后,相关模块就已经被移除。
上一讲中提到的哈希碰撞攻击,就是个典型的例子,对方可以轻易消耗系统有限的 CPU 和线程资源。从这个角度思考,类似加密、解密、图形处理等计算密集型任务,都要防范被恶意滥用,以免攻击者通过直接调用或者间接触发方式,消耗系统资源。
利用 Java 构建类似上传文件或者其他接受输入的服务需要对消耗系统内存或存储的上限有所控制因为我们不能将系统安全依赖于用户的合理使用。其中特别注意的是涉及解压缩功能时就需要防范Zip bomb等特定攻击。
另外Java 程序中需要明确释放的资源有很多种,比如文件描述符、数据库连接,甚至是再入锁,任何情况下都应该保证资源释放成功,否则即使平时能够正常运行,也可能被攻击者利用而耗尽某类资源,这也算是可能的 DoS 攻击来源。
所以可以看出,实现安全的 Java 代码,需要从功能设计到实现细节,都充分考虑可能的安全影响。
考点分析
关于今天的问题,以典型的 DoS 攻击作为切入点,将问题聚焦在 Java 开发中,我介绍了 Java 应用设计、实现的注意事项,后面还会介绍更加全面的实践。
其实安全问题实际就是软件的缺陷,软件安全并不存在一劳永逸的秘籍,既离不开设计、架构中的风险分析,也离不开编码、测试等阶段的安全实践手段。对于面试官来说,考察安全问题,除了对特定安全领域知识的考察,更多是要看面试者的 Java 编程基本功和知识的积累。
所以,我会在后面会循序渐进探讨 Java 安全编程,这里面没有什么黑科技,只有规范的开发标准,很多安全问题其实是态度问题,取决于你是否真的认真对待它。
我将以一些典型的代码片段为出发点,分析一些非常容易被忽略的安全风险,并介绍安全问题频发的热点场景,如 Java 序列化和反序列化。
从软件生命周期的角度,探讨设计、开发、测试、部署等不同阶段,有哪些常见的安全策略或工具。
知识扩展
首先,我们一起来看一段不起眼的条件判断代码,这里可能有什么问题吗?
// a, b, c都是int类型的数值
if (a + b < c) {
//
}
你可能会纳闷这是再常见不过的一个条件判断了能有什么安全隐患
这里的隐患是数值类型需要防范溢出否则这不仅仅可能会带来逻辑错误在特定情况下可能导致严重的安全漏洞
从语言特性来说Java JVM 提供了很多基础性的改进相比于传统的 CC++ 等语言对于数组越界等处理要完善的多原生的避免了缓冲区溢出等攻击方式提高了软件的安全性但这并不代表完全杜绝了问题Java 程序可能调用本地代码也就是 JNI 技术错误的数值可能导致 C/C++ 层面的数据越界等问题这是很危险的
所以上面的条件判断需要判断其数值范围例如写成类似下面结构
if (a < c b)
再来看一个例子请看下面的一段异常处理代码
try {
// 业务代码
} catch (Exception e) {
throw new RuntimeException(hostname + port + doesnt response);
}
这段代码将敏感信息包含在异常消息中试想如果是一个 Web 应用异常也没有良好的包装起来很有可能就把内部信息暴露给终端客户古人曾经告诫我们言多必失是很有道理的虽然其本意不是指软件安全但尽量少暴露信息也是保证安全的基本原则之一即使我们并不认为某个信息有安全风险我的建议也是如果没有必要不要暴露出来
这种暴露还可能通过其他方式发生比如某著名的编程技术网站就被曝光过所有用户名和密码这些信息都是明文存储传输过程也未必进行加密类似这种情况暴露只是个时间早晚的问题
对于安全标准特别高的系统甚至可能要求敏感信息被使用后要立即明确在内存中销毁以免被探测或者避免在发生 core dump 意外暴露
第三Java 提供了序列化等创新的特性广泛使用在远程调用等方面但也带来了复杂的安全问题直到今天序列化仍然是个安全问题频发的场景
针对序列化通常建议
敏感信息不要被序列化在编码中建议使用 transient 关键字将其保护起来
反序列化中建议在 readObject 中实现与对象构件过程相同的安全检查和数据检查
另外 JDK 9 Java 引入了过滤器机制以保证反序列化过程中数据都要经过基本验证才可以使用其原理是通过黑名单和白名单限定安全或者不安全的类型并且你可以进行定制然后通过环境变量灵活进行配置 更加具体的使用你可以参考 ObjectInputFilter
通过前面的介绍你可能注意到很多安全问题都是源于非常基本的编程细节类似 Immutable封装等设计都存在着安全性的考虑从实践的角度让每个人都了解和掌握这些原则有必要但并不太现实有没有什么工程实践手段可以帮助我们排查安全隐患呢
开发和测试阶段
在实际开发中各种功能点五花八门未必能考虑的全面我建议没有必要所有都需要自己去从头实现尽量使用广泛验证过的工具类库不管是来自于 JDK 自身还是 Apache 等第三方组织都在社区的反馈下持续地完善代码安全
开发过程中应用代码规约标准是避免安全问题的有效手段我特别推荐来自孤尽的阿里巴巴 Java 开发手册以及其配套工具充分总结了业界在 Java 等领域的实践经验将规约实践系统性地引入国内的软件开发可以有效提高代码质量
当然凡事都是有代价的规约会增加一定的开发成本可能对迭代的节奏产生一定影响所以对于不同阶段不同需求的团队可以根据自己的情况对规约进行适应性的调整
落实到实际开发流程中 OpenJDK 团队为例我们应用了几个不同角度的实践
在早期设计阶段就由安全专家组对新特性进行风险评估
开发过程中尤其是 code review 阶段应用 OpenJDK 自身定制的代码规范
利用多种静态分析工具如FindBugsParfait等帮助早期发现潜在安全风险并对相应问题采取零容忍态度强制要求解决
甚至 OpenJDK 会默认将任何编译等警告都当作错误对待并体现在 CI 流程中
在代码 check-in 等关键环节利用 hook 机制去调用规则检查工具以保证不合规代码不能进入 OpenJDK 代码库
关于静态分析工具的选择我们选取的原则是足够好没有什么工具能够发现所有问题所以在保证功能的前提下影响更大的是分析效率换句话说是代码分析的噪音高低不管分析有多么的完备如果太多误报就会导致有用信息被噪音覆盖也不利于后续其他程序化的处理反倒不利于排查问题
以上这些是为了保证 JDK 作为基础平台的苛刻质量要求在实际产品中你需要斟酌具体什么程度的要求是合理的
部署阶段
JDK 自身的也是个软件难免会存在实现瑕疵我们平时看到 JDK 更新的安全漏洞补丁其实就是在修补这些漏洞我最近还注意到某大厂后台被曝出了使用的 JDK 版本存在序列化相关的漏洞类似这种情况大多数都是因为使用的 JDK 是较低版本算是可以通过部署解决的问题
如果是安全敏感型产品建议关注 JDK 在加解密方面的路线图同样的标准也应用于其他语言和平台很多早期认为非常安全的算法已经被攻破及时地升级基础软件是安全的必要条件
攻击和防守是不对称的只要有一个严重漏洞对于攻击者就足够了所以不能对黑盒形式的部署心存侥幸这并不能保证系统的安全攻击者可以利用对软件设计的猜测结合一系列手段探测出漏洞
今天我以 DoS 等典型攻击方式为例分析了其在 Java 平台上的特定表现并从更多安全编码的细节帮你体会安全问题的普遍性最后我介绍了软件开发周期中的安全实践希望能对你的工作有所帮助
一课一练
关于今天我们讨论的题目你做到心中有数了吗你在开发中遇到过 Java 特定的安全问题吗是怎么解决的呢
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习奖励礼券欢迎你与我一起讨论
别忘了今晚 8 点半我会做客极客 Live和你一起聊聊 Java 面试那些事儿极客时间App 内点击极客 Live即可加入直播今晚我们不见不散
你的朋友是不是也在准备面试呢你可以请朋友读把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,143 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 如何写出安全的Java代码
在上一讲中我们已经初步接触了Java安全今天我们将一起探讨更多Java开发中可能影响到安全的场合。很多安全问题在特定的上下文存在着不同的定义尽管本质是相似或一致的这是由于Java平台自身的特性所带来特有的问题。今天这一讲我将侧重于Java开发者的角度谈代码安全而不是讲广义的安全风险。
今天我要问你的问题是如何写出安全的Java代码
典型回答
这个问题可能有点宽泛我们可以用特定类型的安全风险为例如拒绝服务DoS攻击分析Java开发者需要重点考虑的点。
DoS是一种常见的网络攻击有人也称其为“洪水攻击”。最常见的表现是利用大量机器发送请求将目标网站的带宽或者其他资源耗尽导致其无法响应正常用户的请求。
我认为从Java语言的角度更加需要重视的是程序级别的攻击也就是利用Java、JVM或应用程序的瑕疵进行低成本的DoS攻击这也是想要写出安全的Java代码所必须考虑的。例如
如果使用的是早期的JDK和Applet等技术攻击者构建合法但恶劣的程序就相对容易例如将其线程优先级设置为最高做一些看起来无害但空耗资源的事情。幸运的是类似技术已经逐步退出历史舞台在JDK 9以后相关模块就已经被移除。
上一讲中提到的哈希碰撞攻击就是个典型的例子对方可以轻易消耗系统有限的CPU和线程资源。从这个角度思考类似加密、解密、图形处理等计算密集型任务都要防范被恶意滥用以免攻击者通过直接调用或者间接触发方式消耗系统资源。
利用Java构建类似上传文件或者其他接受输入的服务需要对消耗系统内存或存储的上限有所控制因为我们不能将系统安全依赖于用户的合理使用。其中特别注意的是涉及解压缩功能时就需要防范Zip bomb等特定攻击。
另外Java程序中需要明确释放的资源有很多种比如文件描述符、数据库连接甚至是再入锁任何情况下都应该保证资源释放成功否则即使平时能够正常运行也可能被攻击者利用而耗尽某类资源这也算是可能的DoS攻击来源。
所以可以看出实现安全的Java代码需要从功能设计到实现细节都充分考虑可能的安全影响。
考点分析
关于今天的问题以典型的DoS攻击作为切入点将问题聚焦在Java开发中我介绍了Java应用设计、实现的注意事项后面还会介绍更加全面的实践。
其实安全问题实际就是软件的缺陷软件安全并不存在一劳永逸的秘籍既离不开设计、架构中的风险分析也离不开编码、测试等阶段的安全实践手段。对于面试官来说考察安全问题除了对特定安全领域知识的考察更多是要看面试者的Java编程基本功和知识的积累。
所以我会在后面会循序渐进探讨Java安全编程这里面没有什么黑科技只有规范的开发标准很多安全问题其实是态度问题取决于你是否真的认真对待它。
我将以一些典型的代码片段为出发点分析一些非常容易被忽略的安全风险并介绍安全问题频发的热点场景如Java序列化和反序列化。
从软件生命周期的角度,探讨设计、开发、测试、部署等不同阶段,有哪些常见的安全策略或工具。
知识扩展
首先,我们一起来看一段不起眼的条件判断代码,这里可能有什么问题吗?
// a, b, c都是int类型的数值
if (a + b < c) {
//
}
你可能会纳闷这是再常见不过的一个条件判断了能有什么安全隐患
这里的隐患是数值类型需要防范溢出否则这不仅仅可能会带来逻辑错误在特定情况下可能导致严重的安全漏洞
从语言特性来说Java和JVM提供了很多基础性的改进相比于传统的CC++等语言对于数组越界等处理要完善的多原生的避免了缓冲区溢出等攻击方式提高了软件的安全性但这并不代表完全杜绝了问题Java程序可能调用本地代码也就是JNI技术错误的数值可能导致C/C++层面的数据越界等问题这是很危险的
所以上面的条件判断需要判断其数值范围例如写成类似下面结构
if (a < c b)
再来看一个例子请看下面的一段异常处理代码
try {
// 业务代码
} catch (Exception e) {
throw new RuntimeException(hostname + port + doesnt response);
}
这段代码将敏感信息包含在异常消息中试想如果是一个Web应用异常也没有良好的包装起来很有可能就把内部信息暴露给终端客户古人曾经告诫我们言多必失是很有道理的虽然其本意不是指软件安全但尽量少暴露信息也是保证安全的基本原则之一即使我们并不认为某个信息有安全风险我的建议也是如果没有必要不要暴露出来
这种暴露还可能通过其他方式发生比如某著名的编程技术网站就被曝光过所有用户名和密码这些信息都是明文存储传输过程也未必进行加密类似这种情况暴露只是个时间早晚的问题
对于安全标准特别高的系统甚至可能要求敏感信息被使用后要立即明确在内存中销毁以免被探测或者避免在发生core dump时意外暴露
第三Java提供了序列化等创新的特性广泛使用在远程调用等方面但也带来了复杂的安全问题直到今天序列化仍然是个安全问题频发的场景
针对序列化通常建议
敏感信息不要被序列化在编码中建议使用transient关键字将其保护起来
反序列化中建议在readObject中实现与对象构件过程相同的安全检查和数据检查
另外在JDK 9中Java引入了过滤器机制以保证反序列化过程中数据都要经过基本验证才可以使用其原理是通过黑名单和白名单限定安全或者不安全的类型并且你可以进行定制然后通过环境变量灵活进行配置 更加具体的使用你可以参考 ObjectInputFilter
通过前面的介绍你可能注意到很多安全问题都是源于非常基本的编程细节类似Immutable封装等设计都存在着安全性的考虑从实践的角度让每个人都了解和掌握这些原则有必要但并不太现实有没有什么工程实践手段可以帮助我们排查安全隐患呢
开发和测试阶段
在实际开发中各种功能点五花八门未必能考虑的全面我建议没有必要所有都需要自己去从头实现尽量使用广泛验证过的工具类库不管是来自于JDK自身还是Apache等第三方组织都在社区的反馈下持续地完善代码安全
开发过程中应用代码规约标准是避免安全问题的有效手段我特别推荐来自孤尽的阿里巴巴Java开发手册以及其配套工具充分总结了业界在Java等领域的实践经验将规约实践系统性地引入国内的软件开发可以有效提高代码质量
当然凡事都是有代价的规约会增加一定的开发成本可能对迭代的节奏产生一定影响所以对于不同阶段不同需求的团队可以根据自己的情况对规约进行适应性的调整
落实到实际开发流程中以OpenJDK团队为例我们应用了几个不同角度的实践
在早期设计阶段就由安全专家组对新特性进行风险评估
开发过程中尤其是code review阶段应用OpenJDK自身定制的代码规范
利用多种静态分析工具如FindBugsParfait等帮助早期发现潜在安全风险并对相应问题采取零容忍态度强制要求解决
甚至OpenJDK会默认将任何编译等警告都当作错误对待并体现在CI流程中
在代码check-in等关键环节利用hook机制去调用规则检查工具以保证不合规代码不能进入OpenJDK代码库
关于静态分析工具的选择我们选取的原则是足够好没有什么工具能够发现所有问题所以在保证功能的前提下影响更大的是分析效率换句话说是代码分析的噪音高低不管分析有多么的完备如果太多误报就会导致有用信息被噪音覆盖也不利于后续其他程序化的处理反倒不利于排查问题
以上这些是为了保证JDK作为基础平台的苛刻质量要求在实际产品中你需要斟酌具体什么程度的要求是合理的
部署阶段
JDK自身的也是个软件难免会存在实现瑕疵我们平时看到JDK更新的安全漏洞补丁其实就是在修补这些漏洞我最近还注意到某大厂后台被曝出了使用的JDK版本存在序列化相关的漏洞类似这种情况大多数都是因为使用的JDK是较低版本算是可以通过部署解决的问题
如果是安全敏感型产品建议关注JDK在加解密方面的路线图同样的标准也应用于其他语言和平台很多早期认为非常安全的算法已经被攻破及时地升级基础软件是安全的必要条件
攻击和防守是不对称的只要有一个严重漏洞对于攻击者就足够了所以不能对黑盒形式的部署心存侥幸这并不能保证系统的安全攻击者可以利用对软件设计的猜测结合一系列手段探测出漏洞
今天我以DoS等典型攻击方式为例分析了其在Java平台上的特定表现并从更多安全编码的细节帮你体会安全问题的普遍性最后我介绍了软件开发周期中的安全实践希望能对你的工作有所帮助
一课一练
关于今天我们讨论的题目你做到心中有数了吗你在开发中遇到过Java特定的安全问题吗是怎么解决的呢
请你在留言区写写你对这个问题的思考我会选出经过认真思考的留言送给你一份学习奖励礼券欢迎你与我一起讨论
别忘了今晚8点半我会做客极客Live和你一起聊聊Java面试那些事儿极客时间App内点击极客Live即可加入直播今晚我们不见不散
你的朋友是不是也在准备面试呢你可以请朋友读把今天的题目分享给好友或许你能帮到他

View File

@@ -0,0 +1,157 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 后台服务出现明显“变慢”,谈谈你的诊断思路?-极客时间
在日常工作中,应用或者系统出现性能问题往往是不可避免的,除了在有一定规模的 IT 企业或者专注于特定性能领域的企业,可能大多数工程师并不会成为专职的性能工程师,但是掌握基本的性能知识和技能,往往是日常工作的需要,并且也是工程师进阶的必要条件之一,能否定位和解决性能问题也是对你知识、技能和能力的检验。
今天我要问你的问题是,后台服务出现明显“变慢”,谈谈你的诊断思路?
典型回答
首先,需要对这个问题进行更加清晰的定义:
服务是突然变慢还是长时间运行后观察到变慢?类似问题是否重复出现?
“慢”的定义是什么,我能够理解是系统对其他方面的请求的反应延时变长吗?
第二,理清问题的症状,这更便于定位具体的原因,有以下一些思路:
问题可能来自于 Java 服务自身,也可能仅仅是受系统里其他服务的影响。初始判断可以先确认是否出现了意外的程序错误,例如检查应用本身的错误日志。
对于分布式系统,很多公司都会实现更加系统的日志、性能等监控系统。一些 Java 诊断工具也可以用于这个诊断,例如通过 JFRJava Flight Recorder监控应用是否大量出现了某种类型的异常。
如果有,那么异常可能就是个突破点。
如果没有,可以先检查系统级别的资源等情况,监控 CPU、内存等资源是否被其他进程大量占用并且这种占用是否不符合系统正常运行状况。
监控 Java 服务自身,例如 GC 日志里面是否观察到 Full GC 等恶劣情况出现,或者是否 Minor GC 在变长等;利用 jstat 等工具,获取内存使用的统计信息也是个常用手段;利用 jstack 等工具检查是否出现死锁等。
如果还不能确定具体问题,对应用进行 Profiling 也是个办法,但因为它会对系统产生侵入性,如果不是非常必要,大多数情况下并不建议在生产系统进行。
定位了程序错误或者 JVM 配置的问题后,就可以采取相应的补救措施,然后验证是否解决,否则还需要重复上面部分过程。
考点分析
今天我选择的是一个常见的并且比较贴近实际应用的的性能相关问题,我提供的回答包括两部分。
在正面回答之前,先探讨更加精确的问题定义是什么。有时候面试官并没有表达清楚,有必要确认自己的理解正确,然后再深入回答。
从系统、应用的不同角度、不同层次,逐步将问题域尽量缩小,隔离出真实原因。具体步骤未必千篇一律,在处理过较多这种问题之后,经验会令你的直觉分外敏感。
大多数工程师也许并没有全面的性能问题诊断机会,如果被问到也不必过于紧张,你可以向面试官展示诊断问题的思考方式,展现自己的知识和综合运用的能力。接触到一个陌生的问题,通过沟通,能够条理清晰地将排查方案逐步确定下来,也是能力的体现。
面试官可能会针对某个角度的诊断深入询问,兼顾工作和面试的需求,我会针对下面一些方面进行介绍。目的是让你对性能分析有个整体的印象,在遇到特定领域问题时,即使不知道具体细节的工具和手段,至少也可以找到探索、查询的方向。
我将介绍业界常见的性能分析方法论。
从系统分析到 JVM、应用性能分析把握整体思路和主要工具。对于线程状态、JVM 内存使用等很多方面,我在专栏前面已经陆陆续续介绍了很多,今天这一讲也可以看作是聚焦性能角度的一个小结。
如果你有兴趣进行系统性的学习,我建议参考 Charlie Hunt 编撰的《Java Performance》或者 Scott Oaks 的《Java PerformanceThe Definitive Guide》。另外如果不希望出现理解偏差最好是阅读英文版。
知识扩展
首先,我们来了解一下业界最广泛的性能分析方法论。
根据系统架构不同分布式系统和大型单体应用也存在着思路的区别例如分布式系统的性能瓶颈可能更加集中。传统意义上的性能调优大多是针对单体应用的调优专栏的侧重点也是如此Charlie Hunt 曾将其方法论总结为两类:
自上而下。从应用的顶层,逐步深入到具体的不同模块,或者更近一步的技术细节单元,找到可能的问题和解决办法。这是最常见的性能分析思路,也是大多数工程师的选择。
自下而上。从类似 CPU 这种硬件底层判断类似Cache-Miss之类的问题和调优机会出发点是指令级别优化。这往往是专业的性能工程师才能掌握的技能并且需要专业工具配合大多数是移植到新的平台上或需要提供极致性能时才会进行。
例如,将大数据应用移植到 SPARC 体系结构的硬件上,需要对比和尽量释放性能潜力,但又希望尽量不改源代码。
我所给出的回答,首先是试图排除功能性错误,然后就是典型的自上而下分析思路。
第二,我们一起来看看自上而下分析中,各个阶段的常见工具和思路。需要注意的是,具体的工具在不同的操作系统上可能区别非常大。
系统性能分析中CPU、内存和 IO 是主要关注项。
对于 CPU如果是常见的 Linux可以先用 top 命令查看负载状况,下图是我截取的一个状态。
可以看到其平均负载load average的三个值分别是 1 分钟、5 分钟、15 分钟)非常低,并且暂时看并没有升高迹象。如果这些数值非常高(例如,超过 50%、60%),并且短期平均值高于长期平均值,则表明负载很重;如果还有升高的趋势,那么就要非常警惕了。
进一步的排查有很多思路,例如,我在专栏第 18 讲曾经问过,怎么找到最耗费 CPU 的 Java 线程,简要介绍步骤:
利用 top 命令获取相应 pid“-H”代表 thread 模式,你可以配合 grep 命令更精准定位。
top H
然后转换成为 16 进制。
printf "%x" your_pid
最后利用 jstack 获取的线程栈,对比相应的 ID 即可。
当然,还有更加通用的诊断方向,利用 vmstat 之类,查看上下文切换的数量,比如下面就是指定时间间隔为 1收集 10 次。
vmstat -1 -10
输出如下:
如果每秒上下文cscontext switch切换很高并且比系统中断高很多insystem interrupt就表明很有可能是因为不合理的多线程调度所导致。当然还需要利用pidstat等手段进行更加具体的定位我就不再进一步展开了。
除了 CPU内存和 IO 是重要的注意事项,比如:
利用 free 之类查看内存使用。
或者,进一步判断 swap 使用情况top 命令输出中 Virt 作为虚拟内存使用量就是物理内存Res和 swap 求和,所以可以反推 swap 使用。显然JVM 是不希望发生大量的 swap 使用的。
对于 IO 问题,既可能发生在磁盘 IO也可能是网络 IO。例如利用 iostat 等命令有助于判断磁盘的健康状况。我曾经帮助诊断过 Java 服务部署在国内的某云厂商机器上,其原因就是 IO 表现较差,拖累了整体性能,解决办法就是申请替换了机器。
讲到这里如果你对系统性能非常感兴趣我建议参考Brendan Gregg提供的完整图谱我所介绍的只能算是九牛一毛。但我还是建议尽量结合实际需求免得迷失在其中。
对于** JVM 层面的性能分析**,我们已经介绍过非常多了:
利用 JMC、JConsole 等工具进行运行时监控。
利用各种工具在运行时进行堆转储分析或者获取各种角度的统计数据如jstat -gcutil 分析 GC、内存分带等
GC 日志等手段,诊断 Full GC、Minor GC或者引用堆积等。
这里并不存在放之四海而皆准的办法,具体问题可能非常不同,还要看你是否能否充分利用这些工具,从种种迹象之中,逐步判断出问题所在。
对于应用Profiling简单来说就是利用一些侵入性的手段收集程序运行时的细节以定位性能问题瓶颈。所谓的细节就是例如内存的使用情况、最频繁调用的方法是什么或者上下文切换的情况等。
我在前面给出的典型回答里提到,一般不建议生产系统进行 Profiling大多数是在性能测试阶段进行。但是当生产系统确实存在这种需求时也不是没有选择。我建议使用 JFR 配合JMC来做 Profiling因为它是从 Hotspot JVM 内部收集底层信息,并经过了大量优化,性能开销非常低,通常是低于 2% 的;并且如此强大的工具,也已经被 Oracle 开源出来!
所以JFR/JMC 完全具备了生产系统 Profiling 的能力,目前也确实在真正大规模部署的云产品上使用过相关技术,快速地定位了问题。
它的使用也非常方便,你不需要重新启动系统或者提前增加配置。例如,你可以在运行时启动 JFR 记录,并将这段时间的信息写入文件:
Jcmd <pid> JFR.start duration=120s filename=myrecording.jfr
然后,使用 JMC 打开“.jfr 文件”就可以进行分析了方法、异常、线程、IO 等应有尽有,其功能非常强大。如果你想了解更多细节,可以参考相关指南。
今天我从一个典型性能问题出发从症状表现到具体的系统分析、JVM 分析,系统性地整理了常见性能分析的思路;并且在知识扩展部分,从方法论和实际操作的角度,让你将理论和实际结合,相信一定可以对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗? 今天的思考题是Profiling 工具获取数据的主要方式有哪些?各有什么优缺点。
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,170 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 后台服务出现明显“变慢”,谈谈你的诊断思路?
在日常工作中应用或者系统出现性能问题往往是不可避免的除了在有一定规模的IT企业或者专注于特定性能领域的企业可能大多数工程师并不会成为专职的性能工程师但是掌握基本的性能知识和技能往往是日常工作的需要并且也是工程师进阶的必要条件之一能否定位和解决性能问题也是对你知识、技能和能力的检验。
今天我要问你的问题是,后台服务出现明显“变慢”,谈谈你的诊断思路?
典型回答
首先,需要对这个问题进行更加清晰的定义:
服务是突然变慢还是长时间运行后观察到变慢?类似问题是否重复出现?
“慢”的定义是什么,我能够理解是系统对其他方面的请求的反应延时变长吗?
第二,理清问题的症状,这更便于定位具体的原因,有以下一些思路:
问题可能来自于Java服务自身也可能仅仅是受系统里其他服务的影响。初始判断可以先确认是否出现了意外的程序错误例如检查应用本身的错误日志。-
对于分布式系统很多公司都会实现更加系统的日志、性能等监控系统。一些Java诊断工具也可以用于这个诊断例如通过JFRJava Flight Recordera>),监控应用是否大量出现了某种类型的异常。-
如果有,那么异常可能就是个突破点。-
如果没有可以先检查系统级别的资源等情况监控CPU、内存等资源是否被其他进程大量占用并且这种占用是否不符合系统正常运行状况。
[* 监控Java服务自身例如GC日志里面是否观察到Full GC等恶劣情况出现或者是否Minor GC在变长等利用jstat等工具获取内存使用的统计信息也是个常用手段利用jstack等工具检查是否出现死锁等。
如果还不能确定具体问题对应用进行Profiling也是个办法但因为它会对系统产生侵入性如果不是非常必要大多数情况下并不建议在生产系统进行。
定位了程序错误或者JVM配置的问题后就可以采取相应的补救措施然后验证是否解决否则还需要重复上面部分过程。
](https://docs.oracle.com/javacomponents/jmc-5-4/jfr-runtime-guide/about.htm#JFRUH173)
[
考点分析
今天我选择的是一个常见的并且比较贴近实际应用的的性能相关问题,我提供的回答包括两部分。
在正面回答之前,先探讨更加精确的问题定义是什么。有时候面试官并没有表达清楚,有必要确认自己的理解正确,然后再深入回答。
从系统、应用的不同角度、不同层次,逐步将问题域尽量缩小,隔离出真实原因。具体步骤未必千篇一律,在处理过较多这种问题之后,经验会令你的直觉分外敏感。
大多数工程师也许并没有全面的性能问题诊断机会,如果被问到也不必过于紧张,你可以向面试官展示诊断问题的思考方式,展现自己的知识和综合运用的能力。接触到一个陌生的问题,通过沟通,能够条理清晰地将排查方案逐步确定下来,也是能力的体现。
面试官可能会针对某个角度的诊断深入询问,兼顾工作和面试的需求,我会针对下面一些方面进行介绍。目的是让你对性能分析有个整体的印象,在遇到特定领域问题时,即使不知道具体细节的工具和手段,至少也可以找到探索、查询的方向。
我将介绍业界常见的性能分析方法论。
从系统分析到JVM、应用性能分析把握整体思路和主要工具。对于线程状态、JVM内存使用等很多方面我在专栏前面已经陆陆续续介绍了很多今天这一讲也可以看作是聚焦性能角度的一个小结。
如果你有兴趣进行系统性的学习我建议参考Charlie Hunt编撰的《Java Performance》或者Scott Oaks的《Java PerformanceThe Definitive Guide》。另外如果不希望出现理解偏差最好是阅读英文版。
知识扩展
首先,我们来了解一下业界最广泛的性能分析方法论。
根据系统架构不同分布式系统和大型单体应用也存在着思路的区别例如分布式系统的性能瓶颈可能更加集中。传统意义上的性能调优大多是针对单体应用的调优专栏的侧重点也是如此Charlie Hunt曾将其方法论总结为两类
](https://docs.oracle.com/javacomponents/jmc-5-4/jfr-runtime-guide/about.htm#JFRUH173)
[* 自上而下。从应用的顶层,逐步深入到具体的不同模块,或者更近一步的技术细节单元,找到可能的问题和解决办法。这是最常见的性能分析思路,也是大多数工程师的选择。
自下而上。从类似CPU这种硬件底层判断类似Cache-Miss之类的问题和调优机会出发点是指令级别优化。这往往是专业的性能工程师才能掌握的技能并且需要专业工具配合大多数是移植到新的平台上或需要提供极致性能时才会进行。
](https://docs.oracle.com/javacomponents/jmc-5-4/jfr-runtime-guide/about.htm#JFRUH173)
例如将大数据应用移植到SPARC体系结构的硬件上需要对比和尽量释放性能潜力但又希望尽量不改源代码。
我所给出的回答,首先是试图排除功能性错误,然后就是典型的自上而下分析思路。
第二,我们一起来看看自上而下分析中,各个阶段的常见工具和思路。需要注意的是,具体的工具在不同的操作系统上可能区别非常大。
系统性能分析中CPU、内存和IO是主要关注项。
对于CPU如果是常见的Linux可以先用top命令查看负载状况下图是我截取的一个状态。-
可以看到其平均负载load average的三个值分别是1分钟、5分钟、15分钟非常低并且暂时看并没有升高迹象。如果这些数值非常高例如超过50%、60%),并且短期平均值高于长期平均值,则表明负载很重;如果还有升高的趋势,那么就要非常警惕了。
进一步的排查有很多思路例如我在专栏第18讲曾经问过怎么找到最耗费CPU的Java线程简要介绍步骤
利用top命令获取相应pid“-H”代表thread模式你可以配合grep命令更精准定位。
top H
然后转换成为16进制。
printf "%x" your_pid
最后利用jstack获取的线程栈对比相应的ID即可。
当然还有更加通用的诊断方向利用vmstat之类查看上下文切换的数量比如下面就是指定时间间隔为1收集10次。
vmstat -1 -10
输出如下:-
如果每秒上下文cscontext switch切换很高并且比系统中断高很多insystem interrupt就表明很有可能是因为不合理的多线程调度所导致。当然还需要利用pidstat等手段进行更加具体的定位我就不再进一步展开了。
除了CPU内存和IO是重要的注意事项比如
利用free之类查看内存使用。
或者进一步判断swap使用情况top命令输出中Virt作为虚拟内存使用量就是物理内存Res和swap求和所以可以反推swap使用。显然JVM是不希望发生大量的swap使用的。
对于IO问题既可能发生在磁盘IO也可能是网络IO。例如利用iostat等命令有助于判断磁盘的健康状况。我曾经帮助诊断过Java服务部署在国内的某云厂商机器上其原因就是IO表现较差拖累了整体性能解决办法就是申请替换了机器。
讲到这里如果你对系统性能非常感兴趣我建议参考Brendan Gregg提供的完整图谱我所介绍的只能算是九牛一毛。但我还是建议尽量结合实际需求免得迷失在其中。-
对于JVM层面的性能分析我们已经介绍过非常多了
利用JMC、JConsole等工具进行运行时监控。
利用各种工具在运行时进行堆转储分析或者获取各种角度的统计数据如jstat -gcutil分析GC、内存分带等
GC日志等手段诊断Full GC、Minor GC或者引用堆积等。
这里并不存在放之四海而皆准的办法,具体问题可能非常不同,还要看你是否能否充分利用这些工具,从种种迹象之中,逐步判断出问题所在。
对于应用Profiling简单来说就是利用一些侵入性的手段收集程序运行时的细节以定位性能问题瓶颈。所谓的细节就是例如内存的使用情况、最频繁调用的方法是什么或者上下文切换的情况等。
我在前面给出的典型回答里提到一般不建议生产系统进行Profiling大多数是在性能测试阶段进行。但是当生产系统确实存在这种需求时也不是没有选择。我建议使用JFR配合JMC来做Profiling因为它是从Hotspot JVM内部收集底层信息并经过了大量优化性能开销非常低通常是低于 2% 的并且如此强大的工具也已经被Oracle开源出来
所以JFR/JMC完全具备了生产系统Profiling的能力目前也确实在真正大规模部署的云产品上使用过相关技术快速地定位了问题。
它的使用也非常方便你不需要重新启动系统或者提前增加配置。例如你可以在运行时启动JFR记录并将这段时间的信息写入文件
Jcmd <pid> JFR.start duration=120s filename=myrecording.jfr
然后使用JMC打开“.jfr文件”就可以进行分析了方法、异常、线程、IO等应有尽有其功能非常强大。如果你想了解更多细节可以参考相关指南。
今天我从一个典型性能问题出发从症状表现到具体的系统分析、JVM分析系统性地整理了常见性能分析的思路并且在知识扩展部分从方法论和实际操作的角度让你将理论和实际结合相信一定可以对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗? 今天的思考题是Profiling工具获取数据的主要方式有哪些各有什么优缺点。
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,207 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 有人说“Lambda能让Java程序慢30倍”你怎么看-极客时间
在上一讲中,我介绍了 Java 性能问题分析的一些基本思路。但在实际工作中,我们不能仅仅等待性能出现问题再去试图解决,而是需要定量的、可对比的方法,去评估 Java 应用性能,来判断其是否能够符合业务支撑目标。今天这一讲,我会介绍从 Java 开发者角度如何从代码级别判断应用的性能表现重点理解最广泛使用的基准测试Benchmark
今天我要问你的问题是有人说“Lambda 能让 Java 程序慢 30 倍”,你怎么看?
为了让你清楚地了解这个背景,请参考下面的代码片段。在实际运行中,基于 Lambda/Stream 的版本lambdaMaxInteger比传统的 for-each 版本forEachLoopMaxInteger慢很多。
// 一个大的ArrayList内部是随机的整形数据
volatile List<Integer> integers = …
// 基准测试1
public int forEachLoopMaxInteger() {
int max = Integer.MIN_VALUE;
for (Integer n : integers) {
max = Integer.max(max, n);
}
return max;
}
// 基准测试2
public int lambdaMaxInteger() {
return integers.stream().reduce(Integer.MIN_VALUE, (a, b) -> Integer.max(a, b));
}
典型回答
我认为“Lambda 能让 Java 程序慢 30 倍”这个争论实际反映了几个方面:
第一,基准测试是一个非常有效的通用手段,让我们以直观、量化的方式,判断程序在特定条件下的性能表现。
第二基准测试必须明确定义自身的范围和目标否则很有可能产生误导的结果。前面代码片段本身的逻辑就有瑕疵更多的开销是源于自动装箱、拆箱auto-boxing/unboxing而不是源自 Lambda 和 Stream所以得出的初始结论是没有说服力的。
第三,虽然 Lambda/Stream 为 Java 提供了强大的函数式编程能力,但是也需要正视其局限性:
一般来说,我们可以认为 Lambda/Stream 提供了与传统方式接近对等的性能,但是如果对于性能非常敏感,就不能完全忽视它在特定场景的性能差异了,例如:初始化的开销。 Lambda 并不算是语法糖而是一种新的工作机制在首次调用时JVM 需要为其构建CallSite实例。这意味着如果 Java 应用启动过程引入了很多 Lambda 语句,会导致启动过程变慢。其实现特点决定了 JVM 对它的优化可能与传统方式存在差异。
增加了程序诊断等方面的复杂性程序栈要复杂很多Fluent 风格本身也不算是对于调试非常友好的结构,并且在可检查异常的处理方面也存在着局限性等。
考点分析
今天的题目是源自于一篇有争议的文章,原文后来更正为“如果 Stream 使用不当,会让你的代码慢 5 倍”。针对这个问题我给出的回答,并没有纠结于所谓的“快”与“慢”,而是从工程实践的角度指出了基准测试本身存在的问题,以及 Lambda 自身的局限性。
从知识点的角度,这个问题考察了我在【专栏第 7 讲】中介绍过的自动装箱 / 拆箱机制对性能的影响,并且考察了 Java 8 中引入的 Lambda 特性的相关知识。除了这些知识点,面试官还可能更加深入探讨如何用基准测试之类的方法,将含糊的观点变成可验证的结论。
对于 Java 语言的很多特性,经常有很多似是而非的 “秘籍”,我们有必要去伪存真,以定量、定性的方式探究真相,探讨更加易于推广的实践。找到结论的能力,比结论本身更重要,因此在今天这一讲中,我们来探讨一下:
基准测试的基础要素,以及如何利用主流框架构建简单的基准测试。
进一步分析,针对保证基准测试的有效性,如何避免偏离测试目的,如何保证基准测试的正确性。
知识扩展
首先,我们先来整体了解一下基准测试的主要目的和特征,专栏里我就不重复那些书面的定义了。
性能往往是特定情景下的评价,泛泛地说性能“好”或者“快”,往往是具有误导性的。通过引入基准测试,我们可以定义性能对比的明确条件、具体的指标,进而保证得到定量的、可重复的对比数据,这是工程中的实际需要。
不同的基准测试其具体内容和范围也存在很大的不同。如果是专业的性能工程师更加熟悉的可能是类似SPEC提供的工业标准的系统级测试而对于大多数 Java 开发者更熟悉的则是范围相对较小、关注点更加细节的微基准测试Micro-Benchmark。我在文章开头提的问题就是典型的微基准测试也是我今天的侧重点。
什么时候需要开发微基准测试呢?
我认为,当需要对一个大型软件的某小部分的性能进行评估时,就可以考虑微基准测试。换句话说,微基准测试大多是 API 级别的验证,或者与其他简单用例场景的对比,例如:
你在开发共享类库,为其他模块提供某种服务的 API 等。
你的 API 对于性能,如延迟、吞吐量有着严格的要求,例如,实现了定制的 HTTP 客户端 API需要明确它对 HTTP 服务器进行大量 GET 请求时的吞吐能力,或者需要对比其他 API保证至少对等甚至更高的性能标准。
所以微基准测试更是偏基础、底层平台开发者的需求,当然,也是那些追求极致性能的前沿工程师的最爱。
如何构建自己的微基准测试,选择什么样的框架比较好?
目前应用最为广泛的框架之一就是JMHOpenJDK 自身也大量地使用 JMH 进行性能对比,如果你是做 Java API 级别的性能对比JMH 往往是你的首选。
JMH 是由 Hotspot JVM 团队专家开发的,除了支持完整的基准测试过程,包括预热、运行、统计和报告等,还支持 Java 和其他 JVM 语言。更重要的是,它针对 Hotspot JVM 提供了各种特性以保证基准测试的正确性整体准确性大大优于其他框架并且JMH 还提供了用近乎白盒的方式进行 Profiling 等工作的能力。
使用 JMH 也非常简单,你可以直接将其依赖加入 Maven 工程,如下图:
也可以,利用类似下面的命令,直接生成一个 Maven 项目。
$ mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=org.sample \
-DartifactId=test \
-Dversion=1.0
JMH 利用注解Annotation定义具体的测试方法以及基准测试的详细配置。例如至少要加上“@Benchmark”以标识它是个基准测试方法,而 BenchmarkMode 则指定了基准测试模式例如下面例子指定了吞吐量Throughput模式还可以根据需要指定平均时间AverageTime等其他模式。
@Benchmark
@BenchmarkMode(Mode.Throughput)
public void testMethod() {
// Put your benchmark code here.
}
当我们实现了具体的测试后,就可以利用下面的 Maven 命令构建。
mvn clean install
运行基准测试则与运行不同的 Java 应用没有明显区别。
java -jar target/benchmarks.jar
更加具体的上手步骤请参考相关指南。JMH 处处透着浓浓的工程师味道,并没有纠结于完善的文档,而是提供了非常棒的样例代码,所以你需要习惯于直接从代码中学习。
如何保证微基准测试的正确性,有哪些坑需要规避?
首先,构建微基准测试,需要从白盒层面理解代码,尤其是具体的性能开销,不管是 CPU 还是内存分配。这有两个方面的考虑第一需要保证我们写出的基准测试符合测试目的确实验证的是我们要覆盖的功能点这一讲的问题就是个典型例子第二通常对于微基准测试我们通常希望代码片段确实是有限的例如执行时间如果需要很多毫秒ms甚至是秒级那么这个有效性就要存疑了也不便于诊断问题所在。
更加重要的是,由于微基准测试基本上都是体量较小的 API 层面测试,最大的威胁来自于过度“聪明”的 JVMBrain Goetz 曾经很早就指出了微基准测试中的典型问题。
由于我们执行的是非常有限的代码片段,必须要保证 JVM 优化过程不影响原始测试目的,下面几个方面需要重点关注:
保证代码经过了足够并且合适的预热。我在【专栏第 1 讲】中提到过,默认情况,在 server 模式下JIT 会在一段代码执行 10000 次后将其编译为本地代码client 模式则是 1500 次以后。我们需要排除代码执行初期的噪音,保证真正采样到的统计数据符合其稳定运行状态。
通常建议使用下面的参数来判断预热工作到底是经过了多久。
-XX:+PrintCompilation
我这里建议考虑另外加上一个参数,否则 JVM 将默认开启后台编译,也就是在其他线程进行,可能导致输出的信息有些混淆。
-Xbatch
与此同时,也要保证预热阶段的代码路径和采集阶段的代码路径是一致的,并且可以观察 PrintCompilation 输出是否在后期运行中仍然有零星的编译语句出现。
防止 JVM 进行无效代码消除Dead Code Elimination例如下面的代码片段中由于我们并没有使用计算结果 mul那么 JVM 就可能直接判断无效代码,根本就不执行它。
public void testMethod() {
int left = 10;
int right = 100;
int mul = left * right;
}
如果你发现代码统计数据发生了数量级程度上的提高,需要警惕是否出现了无效代码消除的问题。
解决办法也很直接,尽量保证方法有返回值,而不是 void 方法,或者使用 JMH 提供的BlackHole设施在方法中添加下面语句。
public void testMethod(Blackhole blackhole) {
// …
blackhole.consume(mul);
}
防止发生常量折叠Constant Folding。JVM 如果发现计算过程是依赖于常量或者事实上的常量就可能会直接计算其结果所以基准测试并不能真实反映代码执行的性能。JMH 提供了 State 机制来解决这个问题,将本地变量修改为 State 对象信息,请参考下面示例。
@State(Scope.Thread)
public static class MyState {
public int left = 10;
public int right = 100;
}
public void testMethod(MyState state, Blackhole blackhole) {
int left = state.left;
int right = state.right;
int mul = left * right;
blackhole.consume(mul);
}
另外 JMH 还会对 State 对象进行额外的处理以尽量消除伪共享False Sharing的影响标记 @StateJMH 会自动进行补齐。
如果你希望确定方法内联Inlining对性能的影响可以考虑打开下面的选项。
-XX:+PrintInlining
从上面的总结,可以看出来微基准测试是一个需要高度了解 Java、JVM 底层机制的技术,是个非常好的深入理解程序背后效果的工具,但是也反映了我们需要审慎对待微基准测试,不被可能的假象蒙蔽。
我今天介绍的内容是相对常见并易于把握的对于微基准测试GC 等基层机制同样会影响其统计数据。我在前面提到,微基准测试通常希望执行时间和内存分配速率都控制在有限范围内,而在这个过程中发生 GC很可能导致数据出现偏差所以 Serial GC 是个值得考虑的选项。另外JDK 11 引入了Epsilon GC可以考虑使用这种什么也不做的 GC 方式,从最大可能性去排除相关影响。
今天我从一个争议性的程序开始,探讨了如何从开发者角度而不是性能工程师角度,利用(微)基准测试验证你在性能上的判断,并且介绍了其基础构建方式和需要重点规避的风险点。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?我们在项目中需要评估系统的容量,以计划和保证其业务支撑能力,谈谈你的思路是怎么样的?常用手段有哪些?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,209 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 有人说“Lambda能让Java程序慢30倍”你怎么看
在上一讲中我介绍了Java性能问题分析的一些基本思路。但在实际工作中我们不能仅仅等待性能出现问题再去试图解决而是需要定量的、可对比的方法去评估Java应用性能来判断其是否能够符合业务支撑目标。今天这一讲我会介绍从Java开发者角度如何从代码级别判断应用的性能表现重点理解最广泛使用的基准测试Benchmark
今天我要问你的问题是有人说“Lambda能让Java程序慢30倍”你怎么看
为了让你清楚地了解这个背景请参考下面的代码片段。在实际运行中基于Lambda/Stream的版本lambdaMaxInteger比传统的for-each版本forEachLoopMaxInteger慢很多。
// 一个大的ArrayList内部是随机的整形数据
volatile List<Integer> integers = …
// 基准测试1
public int forEachLoopMaxInteger() {
int max = Integer.MIN_VALUE;
for (Integer n : integers) {
max = Integer.max(max, n);
}
return max;
}
// 基准测试2
public int lambdaMaxInteger() {
return integers.stream().reduce(Integer.MIN_VALUE, (a, b) -> Integer.max(a, b));
}
典型回答
我认为“Lambda能让Java程序慢30倍”这个争论实际反映了几个方面
第一,基准测试是一个非常有效的通用手段,让我们以直观、量化的方式,判断程序在特定条件下的性能表现。
第二基准测试必须明确定义自身的范围和目标否则很有可能产生误导的结果。前面代码片段本身的逻辑就有瑕疵更多的开销是源于自动装箱、拆箱auto-boxing/unboxing而不是源自Lambda和Stream所以得出的初始结论是没有说服力的。
第三虽然Lambda/Stream为Java提供了强大的函数式编程能力但是也需要正视其局限性
一般来说我们可以认为Lambda/Stream提供了与传统方式接近对等的性能但是如果对于性能非常敏感就不能完全忽视它在特定场景的性能差异了例如初始化的开销。 Lambda并不算是语法糖而是一种新的工作机制在首次调用时JVM需要为其构建CallSite实例。这意味着如果Java应用启动过程引入了很多Lambda语句会导致启动过程变慢。其实现特点决定了JVM对它的优化可能与传统方式存在差异。
增加了程序诊断等方面的复杂性程序栈要复杂很多Fluent风格本身也不算是对于调试非常友好的结构并且在可检查异常的处理方面也存在着局限性等。
考点分析
今天的题目是源自于一篇有争议的文章原文后来更正为“如果Stream使用不当会让你的代码慢5倍”。针对这个问题我给出的回答并没有纠结于所谓的“快”与“慢”而是从工程实践的角度指出了基准测试本身存在的问题以及Lambda自身的局限性。
从知识点的角度,这个问题考察了我在[专栏第7讲]中介绍过的自动装箱/拆箱机制对性能的影响并且考察了Java 8中引入的Lambda特性的相关知识。除了这些知识点面试官还可能更加深入探讨如何用基准测试之类的方法将含糊的观点变成可验证的结论。
对于Java语言的很多特性经常有很多似是而非的 “秘籍”,我们有必要去伪存真,以定量、定性的方式探究真相,探讨更加易于推广的实践。找到结论的能力,比结论本身更重要,因此在今天这一讲中,我们来探讨一下:
基准测试的基础要素,以及如何利用主流框架构建简单的基准测试。
进一步分析,针对保证基准测试的有效性,如何避免偏离测试目的,如何保证基准测试的正确性。
知识扩展
首先,我们先来整体了解一下基准测试的主要目的和特征,专栏里我就不重复那些书面的定义了。
性能往往是特定情景下的评价,泛泛地说性能“好”或者“快”,往往是具有误导性的。通过引入基准测试,我们可以定义性能对比的明确条件、具体的指标,进而保证得到定量的、可重复的对比数据,这是工程中的实际需要。
不同的基准测试其具体内容和范围也存在很大的不同。如果是专业的性能工程师更加熟悉的可能是类似SPEC提供的工业标准的系统级测试而对于大多数Java开发者更熟悉的则是范围相对较小、关注点更加细节的微基准测试Micro-Benchmark。我在文章开头提的问题就是典型的微基准测试也是我今天的侧重点。
什么时候需要开发微基准测试呢?
我认为当需要对一个大型软件的某小部分的性能进行评估时就可以考虑微基准测试。换句话说微基准测试大多是API级别的验证或者与其他简单用例场景的对比例如
你在开发共享类库为其他模块提供某种服务的API等。
你的API对于性能如延迟、吞吐量有着严格的要求例如实现了定制的HTTP客户端API需要明确它对HTTP服务器进行大量GET请求时的吞吐能力或者需要对比其他API保证至少对等甚至更高的性能标准。
所以微基准测试更是偏基础、底层平台开发者的需求,当然,也是那些追求极致性能的前沿工程师的最爱。
如何构建自己的微基准测试,选择什么样的框架比较好?
目前应用最为广泛的框架之一就是JMHOpenJDK自身也大量地使用JMH进行性能对比如果你是做Java API级别的性能对比JMH往往是你的首选。
JMH是由Hotspot JVM团队专家开发的除了支持完整的基准测试过程包括预热、运行、统计和报告等还支持Java和其他JVM语言。更重要的是它针对Hotspot JVM提供了各种特性以保证基准测试的正确性整体准确性大大优于其他框架并且JMH还提供了用近乎白盒的方式进行Profiling等工作的能力。
使用JMH也非常简单你可以直接将其依赖加入Maven工程如下图-
也可以利用类似下面的命令直接生成一个Maven项目。
$ mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=org.sample \
-DartifactId=test \
-Dversion=1.0
JMH利用注解Annotation定义具体的测试方法以及基准测试的详细配置。例如至少要加上“@Benchmark”以标识它是个基准测试方法而BenchmarkMode则指定了基准测试模式例如下面例子指定了吞吐量Throughput模式还可以根据需要指定平均时间AverageTime等其他模式。
@Benchmark
@BenchmarkMode(Mode.Throughput)
public void testMethod() {
// Put your benchmark code here.
}
当我们实现了具体的测试后就可以利用下面的Maven命令构建。
mvn clean install
运行基准测试则与运行不同的Java应用没有明显区别。
java -jar target/benchmarks.jar
更加具体的上手步骤请参考相关指南。JMH处处透着浓浓的工程师味道并没有纠结于完善的文档而是提供了非常棒的样例代码所以你需要习惯于直接从代码中学习。
如何保证微基准测试的正确性,有哪些坑需要规避?
首先构建微基准测试需要从白盒层面理解代码尤其是具体的性能开销不管是CPU还是内存分配。这有两个方面的考虑第一需要保证我们写出的基准测试符合测试目的确实验证的是我们要覆盖的功能点这一讲的问题就是个典型例子第二通常对于微基准测试我们通常希望代码片段确实是有限的例如执行时间如果需要很多毫秒ms甚至是秒级那么这个有效性就要存疑了也不便于诊断问题所在。
更加重要的是由于微基准测试基本上都是体量较小的API层面测试最大的威胁来自于过度“聪明”的JVMBrain Goetz曾经很早就指出了微基准测试中的典型问题。
由于我们执行的是非常有限的代码片段必须要保证JVM优化过程不影响原始测试目的下面几个方面需要重点关注
保证代码经过了足够并且合适的预热。我在[专栏第1讲]中提到过默认情况在server模式下JIT会在一段代码执行10000次后将其编译为本地代码client模式则是1500次以后。我们需要排除代码执行初期的噪音保证真正采样到的统计数据符合其稳定运行状态。-
通常建议使用下面的参数来判断预热工作到底是经过了多久。
-XX:+PrintCompilation
我这里建议考虑另外加上一个参数否则JVM将默认开启后台编译也就是在其他线程进行可能导致输出的信息有些混淆。
-Xbatch
与此同时也要保证预热阶段的代码路径和采集阶段的代码路径是一致的并且可以观察PrintCompilation输出是否在后期运行中仍然有零星的编译语句出现。
防止JVM进行无效代码消除Dead Code Elimination例如下面的代码片段中由于我们并没有使用计算结果mul那么JVM就可能直接判断无效代码根本就不执行它。
public void testMethod() {
int left = 10;
int right = 100;
int mul = left * right;
}
如果你发现代码统计数据发生了数量级程度上的提高,需要警惕是否出现了无效代码消除的问题。
解决办法也很直接尽量保证方法有返回值而不是void方法或者使用JMH提供的BlackHole设施在方法中添加下面语句。
public void testMethod(Blackhole blackhole) {
// …
blackhole.consume(mul);
}
防止发生常量折叠Constant Folding。JVM如果发现计算过程是依赖于常量或者事实上的常量就可能会直接计算其结果所以基准测试并不能真实反映代码执行的性能。JMH提供了State机制来解决这个问题将本地变量修改为State对象信息请参考下面示例。
@State(Scope.Thread)
public static class MyState {
public int left = 10;
public int right = 100;
}
public void testMethod(MyState state, Blackhole blackhole) {
int left = state.left;
int right = state.right;
int mul = left * right;
blackhole.consume(mul);
}
另外JMH还会对State对象进行额外的处理以尽量消除伪共享False Sharing的影响标记@StateJMH会自动进行补齐。
如果你希望确定方法内联Inlining对性能的影响可以考虑打开下面的选项。
-XX:+PrintInlining
从上面的总结可以看出来微基准测试是一个需要高度了解Java、JVM底层机制的技术是个非常好的深入理解程序背后效果的工具但是也反映了我们需要审慎对待微基准测试不被可能的假象蒙蔽。
我今天介绍的内容是相对常见并易于把握的对于微基准测试GC等基层机制同样会影响其统计数据。我在前面提到微基准测试通常希望执行时间和内存分配速率都控制在有限范围内而在这个过程中发生GC很可能导致数据出现偏差所以Serial GC是个值得考虑的选项。另外JDK 11引入了Epsilon GC可以考虑使用这种什么也不做的GC方式从最大可能性去排除相关影响。
今天我从一个争议性的程序开始,探讨了如何从开发者角度而不是性能工程师角度,利用(微)基准测试验证你在性能上的判断,并且介绍了其基础构建方式和需要重点规避的风险点。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?我们在项目中需要评估系统的容量,以计划和保证其业务支撑能力,谈谈你的思路是怎么样的?常用手段有哪些?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,184 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 JVM优化Java代码时都做了什么-极客时间
我在专栏上一讲介绍了微基准测试和相关的注意事项,其核心就是避免 JVM 运行中对 Java 代码的优化导致失真。所以,系统地理解 Java 代码运行过程,有利于在实践中进行更进一步的调优。
今天我要问你的问题是JVM 优化 Java 代码时都做了什么?
与以往我来给出典型回答的方式不同,今天我邀请了隔壁专栏《深入拆解 Java 虚拟机》的作者,同样是来自 Oracle 的郑雨迪博士,让他以 JVM 专家的身份去思考并回答这个问题。
来自 JVM 专栏作者郑雨迪博士的回答
JVM 在对代码执行的优化可分为运行时runtime优化和即时编译器JIT优化。运行时优化主要是解释执行和动态编译通用的一些机制比如说锁机制如偏斜锁、内存分配机制如 TLAB等。除此之外还有一些专门用于优化解释执行效率的比如说模版解释器、内联缓存inline cache用于优化虚方法调用的动态绑定
JVM 的即时编译器优化是指将热点代码以方法为单位转换成机器码,直接运行在底层硬件之上。它采用了多种优化方式,包括静态编译器可以使用的如方法内联、逃逸分析,也包括基于程序运行 profile 的投机性优化speculative/optimistic optimization。这个怎么理解呢比如我有一条 instanceof 指令,在编译之前的执行过程中,测试对象的类一直是同一个,那么即时编译器可以假设编译之后的执行过程中还会是这一个类,并且根据这个类直接返回 instanceof 的结果。如果出现了其他类,那么就抛弃这段编译后的机器码,并且切换回解释执行。
当然JVM 的优化方式仅仅作用在运行应用代码的时候。如果应用代码本身阻塞了,比如说并发时等待另一线程的结果,这就不在 JVM 的优化范畴啦。
考点分析
感谢郑雨迪博士从 JVM 的角度给出的回答。今天这道面试题在专栏里有不少同学问我,也是会在面试时被面试官刨根问底的一个知识点,郑博士的回答已经非常全面和深入啦。
大多数 Java 工程师并不是 JVM 工程师,知识点总归是要落地的,面试官很有可能会从实践的角度探讨,例如,如何在生产实践中,与 JIT 等 JVM 模块进行交互,落实到如何真正进行实际调优。
在今天这一讲,我会从 Java 工程师日常的角度出发,侧重于:
从整体去了解 Java 代码编译、执行的过程,目的是对基本机制和流程有个直观的认识,以保证能够理解调优选择背后的逻辑。
从生产系统调优的角度,谈谈将 JIT 的知识落实到实际工作中的可能思路。这里包括两部分:如何收集 JIT 相关的信息,以及具体的调优手段。
知识扩展
首先,我们从整体的角度来看看 Java 代码的整个生命周期,你可以参考我提供的示意图。
我在【专栏第 1 讲】就已经提到过Java 通过引入字节码这种中间表达方式,屏蔽了不同硬件的差异,由 JVM 负责完成从字节码到机器码的转化。
通常所说的编译期,是指 javac 等编译器或者相关 API 等将源码转换成为字节码的过程,这个阶段也会进行少量类似常量折叠之类的优化,只要利用反编译工具,就可以直接查看细节。
javac 优化与 JVM 内部优化也存在关联毕竟它负责了字节码的生成。例如Java 9 中的字符串拼接,会被 javac 替换成对 StringConcatFactory 的调用,进而为 JVM 进行字符串拼接优化提供了统一的入口。在实际场景中,还可以通过不同的策略选项来干预这个过程
今天我要讲的重点是 JVM 运行时的优化,在通常情况下,编译器和解释器是共同起作用的,具体流程可以参考下面的示意图。
JVM 会根据统计信息动态决定什么方法被编译什么方法解释执行即使是已经编译过的代码也可能在不同的运行阶段不再是热点JVM 有必要将这种代码从 Code Cache 中移除出去,毕竟其大小是有限的。
就如郑博士所回答的,解释器和编译器也会进行一些通用优化,例如:
锁优化,你可以参考我在【专栏第 16 讲】提供的解释器运行时的源码分析。
Intrinsic 机制或者叫作内建方法就是针对特别重要的基础方法JDK 团队直接提供定制的实现,利用汇编或者编译器的中间表达方式编写,然后 JVM 会直接在运行时进行替换。
这么做的理由有很多,例如,不同体系结构的 CPU 在指令等层面存在着差异定制才能充分发挥出硬件的能力。我们日常使用的典型字符串操作、数组拷贝等基础方法Hotspot 都提供了内建实现。
而即时编译器JIT则是更多优化工作的承担者。JIT 对 Java 编译的基本单元是整个方法通过对方法调用的计数统计甄别出热点方法编译为本地代码。另外一个优化场景则是最针对所谓热点循环代码利用通常说的栈上替换技术OSROn-Stack Replacement更加细节请参考R 大的文章),如果方法本身的调用频度还不够编译标准,但是内部有大的循环之类,则还是会有进一步优化的价值。
从理论上来看JIT 可以看作就是基于两个计数器实现,方法计数器和回边计数器提供给 JVM 统计数据,以定位到热点代码。实际中的 JIT 机制要复杂得多,郑博士提到了逃逸分析、循环展开、方法内联等,包括前面提到的 Intrinsic 等通用机制同样会在 JIT 阶段发生。
第二,有哪些手段可以探查这些优化的具体发生情况呢?
专栏中已经陆陆续续介绍了一些,我来简单总结一下并补充部分细节。
打印编译发生的细节。
-XX:UnlockDiagnosticVMOptions -XX:+LogCompilation -XX:LogFile=<your_file_path>
输出更多编译的细节。
-XX:UnlockDiagnosticVMOptions -XX:+LogCompilation -XX:LogFile=<your_file_path>
JVM 会生成一个 xml 形式的文件,另外, LogFile 选项是可选的,不指定则会输出到
hotspot_pid<pid>.log
具体格式可以参考 Ben Evans 提供的JitWatch工具和分析指南。
打印内联的发生,可利用下面的诊断选项,也需要明确解锁。
-XX:+PrintInlining
很多工具都已经提供了具体的统计信息比如JMC、JConsole 之类,我也介绍过使用 NMT 监控其使用。
第三,我们作为应用开发者,有哪些可以触手可及的调优角度和手段呢?
调整热点代码门限值
我曾经介绍过 JIT 的默认门限server 模式默认 10000 次client 是 1500 次。门限大小也存在着调优的可能,可以使用下面的参数调整;与此同时,该参数还可以变相起到降低预热时间的作用。
-XX:CompileThreshold=N
很多人可能会产生疑问,既然是热点,不是早晚会达到门限次数吗?这个还真未必,因为 JVM 会周期性的对计数的数值进行衰减操作,导致调用计数器永远不能达到门限值,除了可以利用 CompileThreshold 适当调整大小,还有一个办法就是关闭计数器衰减。
-XX:-UseCounterDecay
如果你是利用 debug 版本的 JDK还可以利用下面的参数进行试验但是生产版本是不支持这个选项的。
-XX:CounterHalfLifeTime
调整 Code Cache 大小
我们知道 JIT 编译的代码是存储在 Code Cache 中的,需要注意的是 Code Cache 是存在大小限制的,而且不会动态调整。这意味着,如果 Code Cache 太小,可能只有一小部分代码可以被 JIT 编译,其他的代码则没有选择,只能解释执行。所以,一个潜在的调优点就是调整其大小限制。
-XX:ReservedCodeCacheSize=<SIZE>
当然,也可以调整其初始大小。
-XX:InitialCodeCacheSize=<SIZE>
注意,在相对较新版本的 Java 中由于分层编译Tiered-Compilation的存在Code Cache 的空间需求大大增加,其本身默认大小也被提高了。
调整编译器线程数,或者选择适当的编译器模式
JVM 的编译器线程数目与我们选择的模式有关,选择 client 模式默认只有一个编译线程,而 server 模式则默认是两个,如果是当前最普遍的分层编译模式,则会根据 CPU 内核数目计算 C1 和 C2 的数值,你可以通过下面的参数指定的编译线程数。
-XX:CICompilerCount=N
在强劲的多处理器环境中,增大编译线程数,可能更加充分的利用 CPU 资源,让预热等过程更加快速;但是,反之也可能导致编译线程争抢过多资源,尤其是当系统非常繁忙时。例如,系统部署了多个 Java 应用实例的时候,那么减小编译线程数目,则是可以考虑的。
生产实践中,也有人推荐在服务器上关闭分层编译,直接使用 server 编译器,虽然会导致稍慢的预热速度,但是可能在特定工作负载上会有微小的吞吐量提高。
其他一些相对边界比较混淆的所谓“优化”
比如减少进入安全点。严格说它远远不只是发生在动态编译的时候GC 阶段发生的更加频繁,你可以利用下面选项诊断安全点的影响。
-XX:+PrintSafepointStatistics XX:+PrintGCApplicationStoppedTime
注意,在 JDK 9 之后PrintGCApplicationStoppedTime 已经被移除了,你需要使用“-Xlog:safepoint”之类方式来指定。
很多优化阶段都可能和安全点相关,例如:
在 JIT 过程中,逆优化等场景会需要插入安全点。
常规的锁优化阶段也可能发生,比如,偏斜锁的设计目的是为了避免无竞争时的同步开销,但是当真的发生竞争时,撤销偏斜锁会触发安全点,是很重的操作。所以,在并发场景中偏斜锁的价值其实是被质疑的,经常会明确建议关闭偏斜锁。
-XX:-UseBiasedLocking
主要的优化手段就介绍到这里,这些方法都是普通 Java 开发者就可以利用的。如果你想对 JVM 优化手段有更深入的了解,建议你订阅 JVM 专家郑雨迪博士的专栏。
一课一练
关于今天我们讨论的题目你做到心中有数了吗? 请思考一个问题,如何程序化验证 final 关键字是否会影响性能?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,189 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 JVM优化Java代码时都做了什么
我在专栏上一讲介绍了微基准测试和相关的注意事项其核心就是避免JVM运行中对Java代码的优化导致失真。所以系统地理解Java代码运行过程有利于在实践中进行更进一步的调优。
今天我要问你的问题是JVM优化Java代码时都做了什么
与以往我来给出典型回答的方式不同今天我邀请了隔壁专栏《深入拆解Java虚拟机》的作者同样是来自Oracle的郑雨迪博士让他以JVM专家的身份去思考并回答这个问题。
来自JVM专栏作者郑雨迪博士的回答
JVM在对代码执行的优化可分为运行时runtime优化和即时编译器JIT优化。运行时优化主要是解释执行和动态编译通用的一些机制比如说锁机制如偏斜锁、内存分配机制如TLAB等。除此之外还有一些专门用于优化解释执行效率的比如说模版解释器、内联缓存inline cache用于优化虚方法调用的动态绑定
JVM的即时编译器优化是指将热点代码以方法为单位转换成机器码直接运行在底层硬件之上。它采用了多种优化方式包括静态编译器可以使用的如方法内联、逃逸分析也包括基于程序运行profile的投机性优化speculative/optimistic optimization。这个怎么理解呢比如我有一条instanceof指令在编译之前的执行过程中测试对象的类一直是同一个那么即时编译器可以假设编译之后的执行过程中还会是这一个类并且根据这个类直接返回instanceof的结果。如果出现了其他类那么就抛弃这段编译后的机器码并且切换回解释执行。
当然JVM的优化方式仅仅作用在运行应用代码的时候。如果应用代码本身阻塞了比如说并发时等待另一线程的结果这就不在JVM的优化范畴啦。
考点分析
感谢郑雨迪博士从JVM的角度给出的回答。今天这道面试题在专栏里有不少同学问我也是会在面试时被面试官刨根问底的一个知识点郑博士的回答已经非常全面和深入啦。
大多数Java工程师并不是JVM工程师知识点总归是要落地的面试官很有可能会从实践的角度探讨例如如何在生产实践中与JIT等JVM模块进行交互落实到如何真正进行实际调优。
在今天这一讲我会从Java工程师日常的角度出发侧重于
从整体去了解Java代码编译、执行的过程目的是对基本机制和流程有个直观的认识以保证能够理解调优选择背后的逻辑。
从生产系统调优的角度谈谈将JIT的知识落实到实际工作中的可能思路。这里包括两部分如何收集JIT相关的信息以及具体的调优手段。
知识扩展
首先我们从整体的角度来看看Java代码的整个生命周期你可以参考我提供的示意图。-
我在[专栏第1讲]就已经提到过Java通过引入字节码这种中间表达方式屏蔽了不同硬件的差异由JVM负责完成从字节码到机器码的转化。
通常所说的编译期是指javac等编译器或者相关API等将源码转换成为字节码的过程这个阶段也会进行少量类似常量折叠之类的优化只要利用反编译工具就可以直接查看细节。
javac优化与JVM内部优化也存在关联毕竟它负责了字节码的生成。例如Java 9中的字符串拼接会被javac替换成对StringConcatFactory的调用进而为JVM进行字符串拼接优化提供了统一的入口。在实际场景中还可以通过不同的策略选项来干预这个过程。
今天我要讲的重点是JVM运行时的优化在通常情况下编译器和解释器是共同起作用的具体流程可以参考下面的示意图。-
JVM会根据统计信息动态决定什么方法被编译什么方法解释执行即使是已经编译过的代码也可能在不同的运行阶段不再是热点JVM有必要将这种代码从Code Cache中移除出去毕竟其大小是有限的。
就如郑博士所回答的,解释器和编译器也会进行一些通用优化,例如:
锁优化,你可以参考我在[专栏第16讲]提供的解释器运行时的源码分析。
Intrinsic机制或者叫作内建方法就是针对特别重要的基础方法JDK团队直接提供定制的实现利用汇编或者编译器的中间表达方式编写然后JVM会直接在运行时进行替换。
这么做的理由有很多例如不同体系结构的CPU在指令等层面存在着差异定制才能充分发挥出硬件的能力。我们日常使用的典型字符串操作、数组拷贝等基础方法Hotspot都提供了内建实现。
而即时编译器JIT则是更多优化工作的承担者。JIT对Java编译的基本单元是整个方法通过对方法调用的计数统计甄别出热点方法编译为本地代码。另外一个优化场景则是最针对所谓热点循环代码利用通常说的栈上替换技术OSROn-Stack Replacement更加细节请参考R大的文章如果方法本身的调用频度还不够编译标准但是内部有大的循环之类则还是会有进一步优化的价值。
从理论上来看JIT可以看作就是基于两个计数器实现方法计数器和回边计数器提供给JVM统计数据以定位到热点代码。实际中的JIT机制要复杂得多郑博士提到了逃逸分析、循环展开、方法内联等包括前面提到的Intrinsic等通用机制同样会在JIT阶段发生。
第二,有哪些手段可以探查这些优化的具体发生情况呢?
专栏中已经陆陆续续介绍了一些,我来简单总结一下并补充部分细节。
打印编译发生的细节。
-XX:+PrintCompilation
输出更多编译的细节。
-XX:UnlockDiagnosticVMOptions -XX:+LogCompilation -XX:LogFile=<your_file_path>
JVM会生成一个xml形式的文件另外 LogFile选项是可选的不指定则会输出到
hotspot_pid<pid>.log
具体格式可以参考Ben Evans提供的JitWatch工具和分析指南。
打印内联的发生,可利用下面的诊断选项,也需要明确解锁。
-XX:+PrintInlining
如何知晓Code Cache的使用状态呢
很多工具都已经提供了具体的统计信息比如JMC、JConsole之类我也介绍过使用NMT监控其使用。
第三,我们作为应用开发者,有哪些可以触手可及的调优角度和手段呢?
调整热点代码门限值
我曾经介绍过JIT的默认门限server模式默认10000次client是1500次。门限大小也存在着调优的可能可以使用下面的参数调整与此同时该参数还可以变相起到降低预热时间的作用。
-XX:CompileThreshold=N
很多人可能会产生疑问既然是热点不是早晚会达到门限次数吗这个还真未必因为JVM会周期性的对计数的数值进行衰减操作导致调用计数器永远不能达到门限值除了可以利用CompileThreshold适当调整大小还有一个办法就是关闭计数器衰减。
-XX:-UseCounterDecay
如果你是利用debug版本的JDK还可以利用下面的参数进行试验但是生产版本是不支持这个选项的。
-XX:CounterHalfLifeTime
调整Code Cache大小
我们知道JIT编译的代码是存储在Code Cache中的需要注意的是Code Cache是存在大小限制的而且不会动态调整。这意味着如果Code Cache太小可能只有一小部分代码可以被JIT编译其他的代码则没有选择只能解释执行。所以一个潜在的调优点就是调整其大小限制。
-XX:ReservedCodeCacheSize=<SIZE>
当然,也可以调整其初始大小。
-XX:InitialCodeCacheSize=<SIZE>
注意在相对较新版本的Java中由于分层编译Tiered-Compilation的存在Code Cache的空间需求大大增加其本身默认大小也被提高了。
调整编译器线程数,或者选择适当的编译器模式
JVM的编译器线程数目与我们选择的模式有关选择client模式默认只有一个编译线程而server模式则默认是两个如果是当前最普遍的分层编译模式则会根据CPU内核数目计算C1和C2的数值你可以通过下面的参数指定的编译线程数。
-XX:CICompilerCount=N
在强劲的多处理器环境中增大编译线程数可能更加充分的利用CPU资源让预热等过程更加快速但是反之也可能导致编译线程争抢过多资源尤其是当系统非常繁忙时。例如系统部署了多个Java应用实例的时候那么减小编译线程数目则是可以考虑的。
生产实践中也有人推荐在服务器上关闭分层编译直接使用server编译器虽然会导致稍慢的预热速度但是可能在特定工作负载上会有微小的吞吐量提高。
其他一些相对边界比较混淆的所谓“优化”
比如减少进入安全点。严格说它远远不只是发生在动态编译的时候GC阶段发生的更加频繁你可以利用下面选项诊断安全点的影响。
-XX:+PrintSafepointStatistics XX:+PrintGCApplicationStoppedTime
注意在JDK 9之后PrintGCApplicationStoppedTime已经被移除了你需要使用“-Xlog:safepoint”之类方式来指定。
很多优化阶段都可能和安全点相关,例如:
在JIT过程中逆优化等场景会需要插入安全点。
常规的锁优化阶段也可能发生,比如,偏斜锁的设计目的是为了避免无竞争时的同步开销,但是当真的发生竞争时,撤销偏斜锁会触发安全点,是很重的操作。所以,在并发场景中偏斜锁的价值其实是被质疑的,经常会明确建议关闭偏斜锁。
-XX:-UseBiasedLocking
主要的优化手段就介绍到这里这些方法都是普通Java开发者就可以利用的。如果你想对JVM优化手段有更深入的了解建议你订阅JVM专家郑雨迪博士的专栏。
一课一练
关于今天我们讨论的题目你做到心中有数了吗? 请思考一个问题如何程序化验证final关键字是否会影响性能
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,97 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 谈谈MySQL支持的事务隔离级别以及悲观锁和乐观锁的原理和应用场景-极客时间
在日常开发中,尤其是业务开发,少不了利用 Java 对数据库进行基本的增删改查等数据操作,这也是 Java 工程师的必备技能之一。做好数据操作,不仅仅需要对 Java 语言相关框架的掌握,更需要对各种数据库自身体系结构的理解。今天这一讲,作为补充 Java 面试考察知识点的完整性,关于数据库的应用和细节还需要在实践中深入学习。
今天我要问你的问题是,谈谈 MySQL 支持的事务隔离级别,以及悲观锁和乐观锁的原理和应用场景?
典型回答
所谓隔离级别Isolation Level就是在数据库事务中为保证并发数据读写的正确性而提出的定义它并不是 MySQL 专有的概念而是源于ANSI/ISO制定的SQL-92标准。
每种关系型数据库都提供了各自特色的隔离级别实现,虽然在通常的定义中是以锁为实现单元,但实际的实现千差万别。以最常见的 MySQL InnoDB 引擎为例,它是基于 MVCCMulti-Versioning Concurrency Control和锁的复合实现按照隔离程度从低到高MySQL 事务隔离级别分为四个不同层次:
读未提交Read uncommitted就是一个事务能够看到其他事务尚未提交的修改这是最低的隔离水平允许脏读出现。
读已提交Read committed事务能够看到的数据都是其他事务已经提交的修改也就是保证不会看到任何中间性状态当然脏读也不会出现。读已提交仍然是比较低级别的隔离并不保证再次读取时能够获取同样的数据也就是允许其他事务并发修改数据允许不可重复读和幻象读Phantom Read出现。
可重复读Repeatable reads保证同一个事务中多次读取的数据是一致的这是 MySQL InnoDB 引擎的默认隔离级别,但是和一些其他数据库实现不同的是,可以简单认为 MySQL 在可重复读级别不会出现幻象读。
串行化Serializable并发事务之间是串行化的通常意味着读取需要获取共享读锁更新需要获取排他写锁如果 SQL 使用 WHERE 语句还会获取区间锁MySQL 以 GAP 锁形式实现,可重复读级别中默认也会使用),这是最高的隔离级别。
至于悲观锁和乐观锁,也并不是 MySQL 或者数据库中独有的概念,而是并发编程的基本概念。主要区别在于,操作共享数据时,“悲观锁”即认为数据出现冲突的可能性更大,而“乐观锁”则是认为大部分情况不会出现冲突,进而决定是否采取排他性措施。
反映到 MySQL 数据库应用开发中,悲观锁一般就是利用类似 SELECT … FOR UPDATE 这样的语句,对数据加锁,避免其他事务意外修改数据。乐观锁则与 Java 并发包中的 AtomicFieldUpdater 类似,也是利用 CAS 机制,并不会对数据加锁,而是通过对比数据的时间戳或者版本号,来实现乐观锁需要的版本判断。
我认为前面提到的 MVCC其本质就可以看作是种乐观锁机制而排他性的读写锁、双阶段锁等则是悲观锁的实现。
有关它们的应用场景,你可以构建一下简化的火车余票查询和购票系统。同时查询的人可能很多,虽然具体座位票只能是卖给一个人,但余票可能很多,而且也并不能预测哪个查询者会购票,这个时候就更适合用乐观锁。
考点分析
今天的问题来源于实际面试,这两部分问题反映了面试官试图考察面试者在日常应用开发中,是否学习或者思考过数据库内部的机制,是否了解并发相关的基础概念和实践。
我从普通数据库应用开发者的角度,提供了一个相对简化的答案,面试官很有可能进一步从实例的角度展开,例如设计一个典型场景重现脏读、幻象读,或者从数据库设计的角度,可以用哪些手段避免类似情况。我建议你在准备面试时,可以在典型的数据库上试验一下,验证自己的观点。
其他可以考察的点也有很多,在准备这个问题时你也可以对比 Java 语言的并发机制进行深入理解例如随着隔离级别从低到高竞争性Contention逐渐增强随之而来的代价同样是性能和扩展性的下降。
数据库衍生出很多不同的职责方向:
数据库管理员DBA这是一个单独的专业领域。
数据库应用工程师,很多业务开发者就是这种定位,综合利用数据库和其他编程语言等技能,开发业务应用。
数据库工程师,更加侧重于开发数据库、数据库中间件等基础软件。
后面两者与 Java 开发更加相关,但是需要的知识和技能是不同的,所以面试的考察角度也有区别,今天我会分析下对相关知识学习和准备面试的看法。
另外在数据库相关领域Java 工程师最常接触到的就是 O/R Mapping 框架或者类似的数据库交互类库,我会选取最广泛使用的框架进行对比和分析。
知识扩展
首先,我来谈谈对数据库相关领域学习的看法,从最广泛的应用开发者角度,至少需要掌握:
数据库设计基础,包括数据库设计中的几个基本范式,各种数据库的基础概念,例如表、视图、索引、外键、序列号生成器等,清楚如何将现实中业务实体和其依赖关系映射到数据库结构中,掌握典型实体数据应该使用什么样的数据库数据类型等。
每种数据库的设计和实现多少会存在差异,所以至少要精通你使用过的数据库的设计要点。我今天开篇谈到的 MySQL 事务隔离级别,就区别于其他数据库,进一步了解 MVCC、Locking 等机制对于处理进阶问题非常有帮助;还需要了解,不同索引类型的使用,甚至是底层数据结构和算法等。
常见的 SQL 语句,掌握基础的 SQL 调优技巧,至少要了解基本思路是怎样的,例如 SQL 怎样写才能更好利用索引、知道如何分析SQL 执行计划等。
更进一步,至少需要了解针对高并发等特定场景中的解决方案,例如读写分离、分库分表,或者如何利用缓存机制等,目前的数据存储也远不止传统的关系型数据库了。
上面的示意图简单总结了我对数据库领域的理解,希望可以给你进行准备时提供个借鉴。当然在准备面试时并不是一味找一堆书闷头苦读,我还是建议从实际工作中使用的数据库出发,侧重于结合实践,完善和深化自己的知识体系。
接下来我们还是回到 Java 本身,目前最为通用的 Java 和数据库交互技术就是 JDBC最常见的开源框架基本都是构建在 JDBC 之上包括我们熟悉的JPA/Hibernate、MyBatis、Spring JDBC Template 等,各自都有独特的设计特点。
Hibernate 是最负盛名的 O/R Mapping 框架之一,它也是一个 JPA Provider。顾名思义它是以对象为中心的其强项更体现在数据库到 Java 对象的映射,可以很方便地在 Java 对象层面体现外键约束等相对复杂的关系提供了强大的持久化功能。内部大量使用了Lazy-load等技术提高效率。并且为了屏蔽数据库的差异降低维护开销Hibernate 提供了类 SQL 的 HQL可以自动生成某种数据库特定的 SQL 语句。
Hibernate 应用非常广泛,但是过度强调持久化和隔离数据库底层细节,也导致了很多弊端,例如 HQL 需要额外的学习,未必比深入学习 SQL 语言更高效;减弱程序员对 SQL 的直接控制,还可能导致其他代价,本来一句 SQL 的事情,可能被 Hibernate 生成几条,隐藏的内部细节也阻碍了进一步的优化。
而 MyBatis 虽然仍然提供了一些映射的功能,但更加以 SQL 为中心,开发者可以侧重于 SQL 和存储过程,非常简单、直接。如果我们的应用需要大量高性能的或者复杂的 SELECT 语句等,“半自动”的 MyBatis 就会比 Hibernate 更加实用。
而 Spring JDBC Template 也是更加接近于 SQL 层面Spring 本身也可以集成 Hibernate 等 O/R Mapping 框架。
关于这些具体开源框架的学习,我的建议是:
从整体上把握主流框架的架构和设计理念,掌握主要流程,例如 SQL 解析生成、SQL 执行到结果映射等处理过程到底发生了什么。
掌握映射等部分的细节定义和原理,根据我在准备专栏时整理的面试题目,发现很多题目都是偏向于映射定义的细节。
另外,对比不同框架的设计和实现,既有利于你加深理解,也是面试考察的热点方向之一。
今天我从数据库应用开发者的角度,分析了 MySQL 数据库的部分内部机制,并且补充了我对数据库相关面试准备和知识学习的建议,最后对主流 O/R Mapping 等框架进行了简单的对比。
一课一练
关于今天我们讨论的题目你做到心中有数了吗? 今天的思考题是,从架构设计的角度,可以将 MyBatis 分为哪几层?每层都有哪些主要模块?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,107 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 谈谈MySQL支持的事务隔离级别以及悲观锁和乐观锁的原理和应用场景
在日常开发中尤其是业务开发少不了利用Java对数据库进行基本的增删改查等数据操作这也是Java工程师的必备技能之一。做好数据操作不仅仅需要对Java语言相关框架的掌握更需要对各种数据库自身体系结构的理解。今天这一讲作为补充Java面试考察知识点的完整性关于数据库的应用和细节还需要在实践中深入学习。
今天我要问你的问题是谈谈MySQL支持的事务隔离级别以及悲观锁和乐观锁的原理和应用场景
典型回答
所谓隔离级别Isolation Level就是在数据库事务中为保证并发数据读写的正确性而提出的定义它并不是MySQL专有的概念而是源于ANSI/ISO制定的SQL-92标准。
每种关系型数据库都提供了各自特色的隔离级别实现虽然在通常的定义中是以锁为实现单元但实际的实现千差万别。以最常见的MySQL InnoDB引擎为例它是基于 MVCCMulti-Versioning Concurrency Control和锁的复合实现按照隔离程度从低到高MySQL事务隔离级别分为四个不同层次
读未提交Read uncommitted就是一个事务能够看到其他事务尚未提交的修改这是最低的隔离水平允许脏读出现。
读已提交Read committed事务能够看到的数据都是其他事务已经提交的修改也就是保证不会看到任何中间性状态当然脏读也不会出现。读已提交仍然是比较低级别的隔离并不保证再次读取时能够获取同样的数据也就是允许其他事务并发修改数据允许不可重复读和幻象读Phantom Read出现。
可重复读Repeatable reads保证同一个事务中多次读取的数据是一致的这是MySQL InnoDB引擎的默认隔离级别但是和一些其他数据库实现不同的是可以简单认为MySQL在可重复读级别不会出现幻象读。
串行化Serializable并发事务之间是串行化的通常意味着读取需要获取共享读锁更新需要获取排他写锁如果SQL使用WHERE语句还会获取区间锁MySQL以GAP锁形式实现可重复读级别中默认也会使用这是最高的隔离级别。
至于悲观锁和乐观锁也并不是MySQL或者数据库中独有的概念而是并发编程的基本概念。主要区别在于操作共享数据时“悲观锁”即认为数据出现冲突的可能性更大而“乐观锁”则是认为大部分情况不会出现冲突进而决定是否采取排他性措施。
反映到MySQL数据库应用开发中悲观锁一般就是利用类似SELECT … FOR UPDATE这样的语句对数据加锁避免其他事务意外修改数据。乐观锁则与Java并发包中的AtomicFieldUpdater类似也是利用CAS机制并不会对数据加锁而是通过对比数据的时间戳或者版本号来实现乐观锁需要的版本判断。
我认为前面提到的MVCC其本质就可以看作是种乐观锁机制而排他性的读写锁、双阶段锁等则是悲观锁的实现。
有关它们的应用场景,你可以构建一下简化的火车余票查询和购票系统。同时查询的人可能很多,虽然具体座位票只能是卖给一个人,但余票可能很多,而且也并不能预测哪个查询者会购票,这个时候就更适合用乐观锁。
考点分析
今天的问题来源于实际面试,这两部分问题反映了面试官试图考察面试者在日常应用开发中,是否学习或者思考过数据库内部的机制,是否了解并发相关的基础概念和实践。
我从普通数据库应用开发者的角度,提供了一个相对简化的答案,面试官很有可能进一步从实例的角度展开,例如设计一个典型场景重现脏读、幻象读,或者从数据库设计的角度,可以用哪些手段避免类似情况。我建议你在准备面试时,可以在典型的数据库上试验一下,验证自己的观点。
其他可以考察的点也有很多在准备这个问题时你也可以对比Java语言的并发机制进行深入理解例如随着隔离级别从低到高竞争性Contention逐渐增强随之而来的代价同样是性能和扩展性的下降。
数据库衍生出很多不同的职责方向:
数据库管理员DBA这是一个单独的专业领域。
数据库应用工程师,很多业务开发者就是这种定位,综合利用数据库和其他编程语言等技能,开发业务应用。
数据库工程师,更加侧重于开发数据库、数据库中间件等基础软件。
后面两者与Java开发更加相关但是需要的知识和技能是不同的所以面试的考察角度也有区别今天我会分析下对相关知识学习和准备面试的看法。
另外在数据库相关领域Java工程师最常接触到的就是O/R Mapping框架或者类似的数据库交互类库我会选取最广泛使用的框架进行对比和分析。
知识扩展
首先,我来谈谈对数据库相关领域学习的看法,从最广泛的应用开发者角度,至少需要掌握:
数据库设计基础,包括数据库设计中的几个基本范式,各种数据库的基础概念,例如表、视图、索引、外键、序列号生成器等,清楚如何将现实中业务实体和其依赖关系映射到数据库结构中,掌握典型实体数据应该使用什么样的数据库数据类型等。
每种数据库的设计和实现多少会存在差异所以至少要精通你使用过的数据库的设计要点。我今天开篇谈到的MySQL事务隔离级别就区别于其他数据库进一步了解MVCC、Locking等机制对于处理进阶问题非常有帮助还需要了解不同索引类型的使用甚至是底层数据结构和算法等。
常见的SQL语句掌握基础的SQL调优技巧至少要了解基本思路是怎样的例如SQL怎样写才能更好利用索引、知道如何分析SQL执行计划等。
更进一步,至少需要了解针对高并发等特定场景中的解决方案,例如读写分离、分库分表,或者如何利用缓存机制等,目前的数据存储也远不止传统的关系型数据库了。
上面的示意图简单总结了我对数据库领域的理解,希望可以给你进行准备时提供个借鉴。当然在准备面试时并不是一味找一堆书闷头苦读,我还是建议从实际工作中使用的数据库出发,侧重于结合实践,完善和深化自己的知识体系。
接下来我们还是回到Java本身目前最为通用的Java和数据库交互技术就是JDBC最常见的开源框架基本都是构建在JDBC之上包括我们熟悉的JPA/Hibernate、MyBatis、Spring JDBC Template等各自都有独特的设计特点。
Hibernate是最负盛名的O/R Mapping框架之一它也是一个JPA Provider。顾名思义它是以对象为中心的其强项更体现在数据库到Java对象的映射可以很方便地在Java对象层面体现外键约束等相对复杂的关系提供了强大的持久化功能。内部大量使用了Lazy-load等技术提高效率。并且为了屏蔽数据库的差异降低维护开销Hibernate提供了类SQL的HQL可以自动生成某种数据库特定的SQL语句。
Hibernate应用非常广泛但是过度强调持久化和隔离数据库底层细节也导致了很多弊端例如HQL需要额外的学习未必比深入学习SQL语言更高效减弱程序员对SQL的直接控制还可能导致其他代价本来一句SQL的事情可能被Hibernate生成几条隐藏的内部细节也阻碍了进一步的优化。
而MyBatis虽然仍然提供了一些映射的功能但更加以SQL为中心开发者可以侧重于SQL和存储过程非常简单、直接。如果我们的应用需要大量高性能的或者复杂的SELECT语句等“半自动”的MyBatis就会比Hibernate更加实用。
而Spring JDBC Template也是更加接近于SQL层面Spring本身也可以集成Hibernate等O/R Mapping框架。
关于这些具体开源框架的学习,我的建议是:
从整体上把握主流框架的架构和设计理念掌握主要流程例如SQL解析生成、SQL执行到结果映射等处理过程到底发生了什么。
掌握映射等部分的细节定义和原理,根据我在准备专栏时整理的面试题目,发现很多题目都是偏向于映射定义的细节。
另外,对比不同框架的设计和实现,既有利于你加深理解,也是面试考察的热点方向之一。
今天我从数据库应用开发者的角度分析了MySQL数据库的部分内部机制并且补充了我对数据库相关面试准备和知识学习的建议最后对主流O/R Mapping等框架进行了简单的对比。
一课一练
关于今天我们讨论的题目你做到心中有数了吗? 今天的思考题是从架构设计的角度可以将MyBatis分为哪几层每层都有哪些主要模块
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,159 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 谈谈Spring Bean的生命周期和作用域-极客时间
在企业应用软件开发中Java 是毫无争议的主流语言,开放的 Java EE 规范和强大的开源框架功不可没,其中 Spring 毫无疑问已经成为企业软件开发的事实标准之一。今天这一讲,我将补充 Spring 相关的典型面试问题,并谈谈其部分设计细节。
今天我要问你的问题是,
在企业应用软件开发中Java 是毫无争议的主流语言,开放的 Java EE 规范和强大的开源框架功不可没,其中 Spring 毫无疑问已经成为企业软件开发的事实标准之一。今天这一讲,我将补充 Spring 相关的典型面试问题,并谈谈其部分设计细节。
今天我要问你的问题是,谈谈 Spring Bean 的生命周期和作用域?
典型回答
Spring Bean 生命周期比较复杂,可以分为创建和销毁两个过程。
首先,创建 Bean 会经过一系列的步骤,主要包括:
实例化 Bean 对象。
设置 Bean 属性。
如果我们通过各种 Aware 接口声明了依赖关系,则会注入 Bean 对容器基础设施层面的依赖。具体包括 BeanNameAware、BeanFactoryAware 和 ApplicationContextAware分别会注入 Bean ID、Bean Factory 或者 ApplicationContext。
调用 BeanPostProcessor 的前置初始化方法 postProcessBeforeInitialization。
如果实现了 InitializingBean 接口,则会调用 afterPropertiesSet 方法。
调用 Bean 自身定义的 init 方法。
调用 BeanPostProcessor 的后置初始化方法 postProcessAfterInitialization。
创建过程完毕。
你可以参考下面示意图理解这个具体过程和先后顺序。
第二Spring Bean 的销毁过程会依次调用 DisposableBean 的 destroy 方法和 Bean 自身定制的 destroy 方法。
Spring Bean 有五个作用域,其中最基础的有下面两种:
Singleton这是 Spring 的默认作用域,也就是为每个 IOC 容器创建唯一的一个 Bean 实例。
Prototype针对每个 getBean 请求,容器都会单独创建一个 Bean 实例。
从 Bean 的特点来看Prototype 适合有状态的 Bean而 Singleton 则更适合无状态的情况。另外,使用 Prototype 作用域需要经过仔细思考,毕竟频繁创建和销毁 Bean 是有明显开销的。
如果是 Web 容器,则支持另外三种作用域:
Request为每个 HTTP 请求创建单独的 Bean 实例。
Session很显然 Bean 实例的作用域是 Session 范围。
GlobalSession用于 Portlet 容器,因为每个 Portlet 有单独的 SessionGlobalSession 提供一个全局性的 HTTP Session。
考点分析
今天我选取的是一个入门性质的高频 Spring 面试题目,我认为相比于记忆题目典型回答里的细节步骤,理解和思考 Bean 生命周期所体现出来的 Spring 设计和机制更有意义。
你能看到Bean 的生命周期是完全被容器所管理的从属性设置到各种依赖关系都是容器负责注入并进行各个阶段其他事宜的处理Spring 容器为应用开发者定义了清晰的生命周期沟通界面。
如果从具体 API 设计和使用技巧来看,还记得我在【专栏第 13 讲】提到过的 Marker Interface 吗Aware 接口就是个典型应用例子Bean 可以实现各种不同 Aware 的子接口,为容器以 Callback 形式注入依赖对象提供了统一入口。
言归正传,还是回到 Spring 的学习和面试。关于 Spring也许一整本书都无法完整涵盖其内容专栏里我会有限地补充
Spring 的基础机制。
Spring 框架的涵盖范围。
Spring AOP 自身设计的一些细节,前面第【 24 讲】偏重于底层实现原理,这样还不够全面,毕竟不管是动态代理还是字节码操纵,都还只是基础,更需要 Spring 层面对切面编程的支持。
知识扩展
首先,我们先来看看 Spring 的基础机制,至少你需要理解下面两个基本方面。
控制反转Inversion of Control或者也叫依赖注入Dependency Injection广泛应用于 Spring 框架之中,可以有效地改善了模块之间的紧耦合问题。
从 Bean 创建过程可以看到它的依赖关系都是由容器负责注入具体实现方式包括带参数的构造函数、setter 方法或者AutoWired方式实现。
AOP我们已经在前面接触过这种切面编程机制Spring 框架中的事务、安全、日志等功能都依赖于 AOP 技术,下面我会进一步介绍。
第二Spring 到底是指什么?
我前面谈到的 Spring其实是狭义的Spring Framework其内部包含了依赖注入、事件机制等核心模块也包括事务、O/R Mapping 等功能组成的数据访问模块,以及 Spring MVC 等 Web 框架和其他基础组件。
广义上的 Spring 已经成为了一个庞大的生态系统,例如:
Spring Boot通过整合通用实践更加自动、智能的依赖管理等Spring Boot 提供了各种典型应用领域的快速开发基础,所以它是以应用为中心的一个框架集合。
Spring Cloud可以看作是在 Spring Boot 基础上发展出的更加高层次的框架,它提供了构建分布式系统的通用模式,包含服务发现和服务注册、分布式配置管理、负载均衡、分布式诊断等各种子系统,可以简化微服务系统的构建。
当然,还有针对特定领域的 Spring Security、Spring Data 等。
上面的介绍比较笼统,针对这么多内容,如果将目标定得太过宽泛,可能就迷失在 Spring 生态之中,我建议还是深入你当前使用的模块,如 Spring MVC。并且从整体上把握主要前沿框架如 Spring Cloud的应用范围和内部设计至少要了解主要组件和具体用途毕竟如何构建微服务等已经逐渐成为 Java 应用开发面试的热点之一。
第三,我们来探讨一下更多有关 Spring AOP 自身设计和实现的细节。
先问一下自己,我们为什么需要切面编程呢?
切面编程落实到软件工程其实是为了更好地模块化,而不仅仅是为了减少重复代码。通过 AOP 等机制,我们可以把横跨多个不同模块的代码抽离出来,让模块本身变得更加内聚,进而业务开发者可以更加专注于业务逻辑本身。从迭代能力上来看,我们可以通过切面的方式进行修改或者新增功能,这种能力不管是在问题诊断还是产品能力扩展中,都非常有用。
在之前的分析中,我们已经分析了 AOP Proxy 的实现原理,简单回顾一下,它底层是基于 JDK 动态代理或者 cglib 字节码操纵等技术,运行时动态生成被调用类型的子类等,并实例化代理对象,实际的方法调用会被代理给相应的代理对象。但是,这并没有解释具体在 AOP 设计层面,什么是切面,如何定义切入点和切面行为呢?
Spring AOP 引入了其他几个关键概念:
Aspect通常叫作方面它是跨不同 Java 类层面的横切性逻辑。在实现形式上,既可以是 XML 文件中配置的普通类,也可以在类代码中用“@Aspect”注解去声明。在运行时Spring 框架会创建类似Advisor来指代它其内部会包括切入的时机Pointcut和切入的动作Advice
Join Point它是 Aspect 可以切入的特定点,在 Spring 里面只有方法可以作为 Join Point。
Advice它定义了切面中能够采取的动作。如果你去看 Spring 源码,就会发现 Advice、Join Point 并没有定义在 Spring 自己的命名空间里这是因为他们是源自AOP 联盟,可以看作是 Java 工程师在 AOP 层面沟通的通用规范。
Java 核心类库中同样存在类似代码,例如 Java 9 中引入的 Flow API 就是 Reactive Stream 规范的最小子集,通过这种方式,可以保证不同产品直接的无缝沟通,促进了良好实践的推广。
具体的 Spring Advice 结构请参考下面的示意图。
其中BeforeAdvice 和 AfterAdvice 包括它们的子接口是最简单的实现。而 Interceptor 则是所谓的拦截器,用于拦截住方法(也包括构造器)调用事件,进而采取相应动作,所以 Interceptor 是覆盖住整个方法调用过程的 Advice。通常将拦截器类型的 Advice 叫作 Around在代码中可以使用“@Around”来标记,或者在配置中使用<aop:around>
如果从时序上来看,则可以参考下图,理解具体发生的时机。
Pointcut它负责具体定义 Aspect 被应用在哪些 Join Point可以通过指定具体的类名和方法名来实现或者也可以使用正则表达式来定义条件。
你可以参看下面的示意图,来进一步理解上面这些抽象在逻辑上的意义。
Join Point 仅仅是可利用的机会。
Pointcut 是解决了切面编程中的 Where 问题,让程序可以知道哪些机会点可以应用某个切面动作。
而 Advice 则是明确了切面编程中的 What也就是做什么同时通过指定 Before、After 或者 Around定义了 When也就是什么时候做。
在准备面试时,如果在实践中使用过 AOP 是最好的,否则你可以选择一个典型的 AOP 实例,理解具体的实现语法细节,因为在面试考察中也许会问到这些技术细节。
如果你有兴趣深入内部,最好可以结合 Bean 生命周期,理解 Spring 如何解析 AOP 相关的注解或者配置项何时何地使用到动态代理等机制。为了避免被庞杂的源码弄晕我建议你可以从比较精简的测试用例作为一个切入点如CglibProxyTests。
另外Spring 框架本身功能点非常多AOP 并不是它所支持的唯一切面技术,它只能利用动态代理进行运行时编织,而不能进行编译期的静态编织或者类加载期编织。例如,在 Java 平台上,我们可以使用 Java Agent 技术,在类加载过程中对字节码进行操纵,比如修改或者替换方法实现等。在 Spring 体系中,如何做到类似功能呢?你可以使用 AspectJ它具有更加全面的能力当然使用也更加复杂。
今天我从一个常见的 Spring 面试题开始,浅谈了 Spring 的基础机制,探讨了 Spring 生态范围,并且补充分析了部分 AOP 的设计细节,希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,请介绍一下 Spring 声明式事务的实现机制,可以考虑将具体过程画图。
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,171 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 谈谈Spring Bean的生命周期和作用域
在企业应用软件开发中Java是毫无争议的主流语言开放的Java EE规范和强大的开源框架功不可没其中Spring毫无疑问已经成为企业软件开发的事实标准之一。今天这一讲我将补充Spring相关的典型面试问题并谈谈其部分设计细节。
今天我要问你的问题是谈谈Spring Bean的生命周期和作用域
典型回答
Spring Bean生命周期比较复杂可以分为创建和销毁两个过程。
首先创建Bean会经过一系列的步骤主要包括
实例化Bean对象。
设置Bean属性。
如果我们通过各种Aware接口声明了依赖关系则会注入Bean对容器基础设施层面的依赖。具体包括BeanNameAware、BeanFactoryAware和ApplicationContextAware分别会注入Bean ID、Bean Factory或者ApplicationContext。
调用BeanPostProcessor的前置初始化方法postProcessBeforeInitialization。
如果实现了InitializingBean接口则会调用afterPropertiesSet方法。
调用Bean自身定义的init方法。
调用BeanPostProcessor的后置初始化方法postProcessAfterInitialization。
创建过程完毕。
你可以参考下面示意图理解这个具体过程和先后顺序。-
第二Spring Bean的销毁过程会依次调用DisposableBean的destroy方法和Bean自身定制的destroy方法。
Spring Bean有五个作用域其中最基础的有下面两种
Singleton这是Spring的默认作用域也就是为每个IOC容器创建唯一的一个Bean实例。
Prototype针对每个getBean请求容器都会单独创建一个Bean实例。
从Bean的特点来看Prototype适合有状态的Bean而Singleton则更适合无状态的情况。另外使用Prototype作用域需要经过仔细思考毕竟频繁创建和销毁Bean是有明显开销的。
如果是Web容器则支持另外三种作用域
Request为每个HTTP请求创建单独的Bean实例。
Session很显然Bean实例的作用域是Session范围。
GlobalSession用于Portlet容器因为每个Portlet有单独的SessionGlobalSession提供一个全局性的HTTP Session。
考点分析
今天我选取的是一个入门性质的高频Spring面试题目我认为相比于记忆题目典型回答里的细节步骤理解和思考Bean生命周期所体现出来的Spring设计和机制更有意义。
你能看到Bean的生命周期是完全被容器所管理的从属性设置到各种依赖关系都是容器负责注入并进行各个阶段其他事宜的处理Spring容器为应用开发者定义了清晰的生命周期沟通界面。
如果从具体API设计和使用技巧来看还记得我在[专栏第13讲]提到过的Marker Interface吗Aware接口就是个典型应用例子Bean可以实现各种不同Aware的子接口为容器以Callback形式注入依赖对象提供了统一入口。
言归正传还是回到Spring的学习和面试。关于Spring也许一整本书都无法完整涵盖其内容专栏里我会有限地补充
Spring的基础机制。
Spring框架的涵盖范围。
Spring AOP自身设计的一些细节前面[第24讲]偏重于底层实现原理这样还不够全面毕竟不管是动态代理还是字节码操纵都还只是基础更需要Spring层面对切面编程的支持。
知识扩展
首先我们先来看看Spring的基础机制至少你需要理解下面两个基本方面。
控制反转Inversion of Control或者也叫依赖注入Dependency Injection广泛应用于Spring框架之中可以有效地改善了模块之间的紧耦合问题。
从Bean创建过程可以看到它的依赖关系都是由容器负责注入具体实现方式包括带参数的构造函数、setter方法或者AutoWired方式实现。
AOP我们已经在前面接触过这种切面编程机制Spring框架中的事务、安全、日志等功能都依赖于AOP技术下面我会进一步介绍。
第二Spring到底是指什么
我前面谈到的Spring其实是狭义的Spring Framework其内部包含了依赖注入、事件机制等核心模块也包括事务、O/R Mapping等功能组成的数据访问模块以及Spring MVC等Web框架和其他基础组件。
广义上的Spring已经成为了一个庞大的生态系统例如
Spring Boot通过整合通用实践更加自动、智能的依赖管理等Spring Boot提供了各种典型应用领域的快速开发基础所以它是以应用为中心的一个框架集合。
Spring Cloud可以看作是在Spring Boot基础上发展出的更加高层次的框架它提供了构建分布式系统的通用模式包含服务发现和服务注册、分布式配置管理、负载均衡、分布式诊断等各种子系统可以简化微服务系统的构建。
当然还有针对特定领域的Spring Security、Spring Data等。
上面的介绍比较笼统针对这么多内容如果将目标定得太过宽泛可能就迷失在Spring生态之中我建议还是深入你当前使用的模块如Spring MVC。并且从整体上把握主要前沿框架如Spring Cloud的应用范围和内部设计至少要了解主要组件和具体用途毕竟如何构建微服务等已经逐渐成为Java应用开发面试的热点之一。
第三我们来探讨一下更多有关Spring AOP自身设计和实现的细节。
先问一下自己,我们为什么需要切面编程呢?
切面编程落实到软件工程其实是为了更好地模块化而不仅仅是为了减少重复代码。通过AOP等机制我们可以把横跨多个不同模块的代码抽离出来让模块本身变得更加内聚进而业务开发者可以更加专注于业务逻辑本身。从迭代能力上来看我们可以通过切面的方式进行修改或者新增功能这种能力不管是在问题诊断还是产品能力扩展中都非常有用。
在之前的分析中我们已经分析了AOP Proxy的实现原理简单回顾一下它底层是基于JDK动态代理或者cglib字节码操纵等技术运行时动态生成被调用类型的子类等并实例化代理对象实际的方法调用会被代理给相应的代理对象。但是这并没有解释具体在AOP设计层面什么是切面如何定义切入点和切面行为呢
Spring AOP引入了其他几个关键概念
Aspect通常叫作方面它是跨不同Java类层面的横切性逻辑。在实现形式上既可以是XML文件中配置的普通类也可以在类代码中用“@Aspect”注解去声明。在运行时Spring框架会创建类似Advisor来指代它其内部会包括切入的时机Pointcut和切入的动作Advice
Join Point它是Aspect可以切入的特定点在Spring里面只有方法可以作为Join Point。
Advice它定义了切面中能够采取的动作。如果你去看Spring源码就会发现Advice、Join Point并没有定义在Spring自己的命名空间里这是因为他们是源自AOP联盟可以看作是Java工程师在AOP层面沟通的通用规范。
Java核心类库中同样存在类似代码例如Java 9中引入的Flow API就是Reactive Stream规范的最小子集通过这种方式可以保证不同产品直接的无缝沟通促进了良好实践的推广。
具体的Spring Advice结构请参考下面的示意图。-
其中BeforeAdvice和AfterAdvice包括它们的子接口是最简单的实现。而Interceptor则是所谓的拦截器用于拦截住方法也包括构造器调用事件进而采取相应动作所以Interceptor是覆盖住整个方法调用过程的Advice。通常将拦截器类型的Advice叫作Around在代码中可以使用“@Around”来标记或者在配置中使用“aop:around”。
如果从时序上来看,则可以参考下图,理解具体发生的时机。
Pointcut它负责具体定义Aspect被应用在哪些Join Point可以通过指定具体的类名和方法名来实现或者也可以使用正则表达式来定义条件。
你可以参看下面的示意图,来进一步理解上面这些抽象在逻辑上的意义。
Join Point仅仅是可利用的机会。
Pointcut是解决了切面编程中的Where问题让程序可以知道哪些机会点可以应用某个切面动作。
而Advice则是明确了切面编程中的What也就是做什么同时通过指定Before、After或者Around定义了When也就是什么时候做。
在准备面试时如果在实践中使用过AOP是最好的否则你可以选择一个典型的AOP实例理解具体的实现语法细节因为在面试考察中也许会问到这些技术细节。
如果你有兴趣深入内部最好可以结合Bean生命周期理解Spring如何解析AOP相关的注解或者配置项何时何地使用到动态代理等机制。为了避免被庞杂的源码弄晕我建议你可以从比较精简的测试用例作为一个切入点如CglibProxyTests。
另外Spring框架本身功能点非常多AOP并不是它所支持的唯一切面技术它只能利用动态代理进行运行时编织而不能进行编译期的静态编织或者类加载期编织。例如在Java平台上我们可以使用Java Agent技术在类加载过程中对字节码进行操纵比如修改或者替换方法实现等。在Spring体系中如何做到类似功能呢你可以使用AspectJ它具有更加全面的能力当然使用也更加复杂。
今天我从一个常见的Spring面试题开始浅谈了Spring的基础机制探讨了Spring生态范围并且补充分析了部分AOP的设计细节希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是请介绍一下Spring声明式事务的实现机制可以考虑将具体过程画图。
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,127 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
38 对比Java标准NIO类库你知道Netty是如何实现更高性能的吗
今天我会对NIO进行一些补充在[专栏第11讲]中我们初步接触了Java提供的几种IO机制作为语言基础类库Java自身的NIO设计更偏底层这本无可厚非但是对于一线的应用开发者其复杂性、扩展性等方面就存在一定的局限了。在基础NIO之上Netty构建了更加易用、高性能的网络框架广泛应用于互联网、游戏、电信等各种领域。
今天我要问你的问题是对比Java标准NIO类库你知道Netty是如何实现更高性能的吗
典型回答
单独从性能角度Netty在基础的NIO等类库之上进行了很多改进例如
更加优雅的Reactor模式实现、灵活的线程模型、利用EventLoop等创新性的机制可以非常高效地管理成百上千的Channel。
充分利用了Java的Zero-Copy机制并且从多种角度“斤斤计较”般的降低内存分配和回收的开销。例如使用池化的Direct Buffer等技术在提高IO性能的同时减少了对象的创建和销毁利用反射等技术直接操纵SelectionKey使用数组而不是Java容器等。
使用更多本地代码。例如直接利用JNI调用Open SSL等方式获得比Java内建SSL引擎更好的性能。
在通信协议、序列化等其他角度的优化。
总的来说Netty并没有Java核心类库那些强烈的通用性、跨平台等各种负担针对性能等特定目标以及Linux等特定环境采取了一些极致的优化手段。
考点分析
这是一个比较开放的问题,我给出的回答是个概要性的举例说明。面试官很可能利用这种开放问题作为引子,针对你回答的一个或者多个点,深入探讨你在不同层次上的理解程度。
在面试准备中兼顾整体性的同时不要忘记选定个别重点进行深入理解掌握最好是进行源码层面的深入阅读和实验。如果你希望了解更多从性能角度Netty在编码层面的手段可以参考Norman在Devoxx上的分享其中的很多技巧对于实现极致性能的API有一定借鉴意义但在一般的业务开发中要谨慎采用。
虽然提到Netty人们会自然地想到高性能但是Netty本身的优势不仅仅只有这一个方面
下面我会侧重两个方面:
对Netty进行整体介绍帮你了解其基本组成。
从一个简单的例子开始,对比在[第11讲]中基于IO、NIO等标准API的实例分析它的技术要点给你提供一个进一步深入学习的思路。
知识扩展
首先我们从整体了解一下Netty。按照官方定义它是一个异步的、基于事件Client/Server的网络框架目标是提供一种简单、快速构建网络应用的方式同时保证高吞吐量、低延时、高可靠性。
从设计思路和目的上Netty与Java自身的NIO框架相比有哪些不同呢
我们知道Java的标准类库由于其基础性、通用性的定位往往过于关注技术模型上的抽象而不是从一线应用开发者的角度去思考。我曾提到过引入并发包的一个重要原因就是应用开发者使用Thread API比较痛苦需要操心的不仅仅是业务逻辑而且还要自己负责将其映射到Thread模型上。Java NIO的设计也有类似的特点开发者需要深入掌握线程、IO、网络等相关概念学习路径很长很容易导致代码复杂、晦涩即使是有经验的工程师也难以快速地写出高可靠性的实现。
Netty的设计强调了 “Separation Of Concerns”通过精巧设计的事件机制将业务逻辑和无关技术逻辑进行隔离并通过各种方便的抽象一定程度上填补了了基础平台和业务开发之间的鸿沟更有利于在应用开发中普及业界的最佳实践。
另外Netty > java.nio + java. net
从API能力范围来看Netty完全是Java NIO框架的一个大大的超集你可以参考Netty官方的模块划分。
除了核心的事件机制等Netty还额外提供了很多功能例如
从网络协议的角度Netty除了支持传输层的UDP、TCP、SCTP协议也支持HTTP(s)、WebSocket等多种应用层协议它并不是单一协议的API。
在应用中需要将数据从Java对象转换成为各种应用协议的数据格式或者进行反向的转换Netty为此提供了一系列扩展的编解码框架与应用开发场景无缝衔接并且性能良好。
它扩展了Java NIO Buffer提供了自己的ByteBuf实现并且深度支持Direct Buffer等技术甚至hack了Java内部对Direct Buffer的分配和销毁等。同时Netty也提供了更加完善的Scatter/Gather机制实现。
可以看到Netty的能力范围大大超过了Java核心类库中的NIO等API可以说它是一个从应用视角出发的产物。
当然对于基础API设计Netty也有自己独到的见解未来Java NIO API也可能据此进行一定的改进如果你有兴趣可以参考JDK-8187540。
接下来我们一起来看一个入门的代码实例看看Netty应用到底是什么样子。
与[第11讲]类似同样是以简化的Echo Server为例下图是Netty官方提供的Server部分完整用例请点击链接。
上面的例子虽然代码很短但已经足够体现出Netty的几个核心概念请注意我用红框标记出的部分
ServerBootstrap服务器端程序的入口这是Netty为简化网络程序配置和关闭等生命周期管理所引入的Bootstrapping机制。我们通常要做的创建Channel、绑定端口、注册Handler等都可以通过这个统一的入口以Fluent API等形式完成相对简化了API使用。与之相对应 Bootstrap则是Client端的通常入口。
Channel作为一个基于NIO的扩展框架Channel和Selector等概念仍然是Netty的基础组件但是针对应用开发具体需求提供了相对易用的抽象。
EventLoop这是Netty处理事件的核心机制。例子中使用了EventLoopGroup。我们在NIO中通常要做的几件事情如注册感兴趣的事件、调度相应的Handler等都是EventLoop负责。
ChannelFuture这是Netty实现异步IO的基础之一保证了同一个Channel操作的调用顺序。Netty扩展了Java标准的Future提供了针对自己场景的特有Future定义。
ChannelHandler这是应用开发者放置业务逻辑的主要地方也是我上面提到的“Separation Of Concerns”原则的体现。
ChannelPipeline它是ChannelHandler链条的容器每个Channel在创建后自动被分配一个ChannelPipeline。在上面的示例中我们通过ServerBootstrap注册了ChannelInitializer并且实现了initChannel方法而在该方法中则承担了向ChannelPipleline安装其他Handler的任务。
你可以参考下面的简化示意图忽略Inbound/OutBound Handler的细节理解这几个基本单元之间的操作流程和对应关系。
对比Java标准NIO的代码Netty提供的相对高层次的封装减少了对Selector等细节的操纵而EventLoop、Pipeline等机制则简化了编程模型开发者不用担心并发等问题在一定程度上简化了应用代码的开发。最难能可贵的是这一切并没有以可靠性、可扩展性为代价反而将其大幅度提高。
我在[专栏周末福利]中已经推荐了Norman Maurer等编写的《Netty实战》Netty In Action如果你想系统学习Netty它会是个很好的入门参考。针对Netty的一些实现原理很可能成为面试中的考点例如
Reactor模式和Netty线程模型。
Pipelining、EventLoop等部分的设计实现细节。
Netty的内存管理机制、引用计数等特别手段。
有的时候面试官也喜欢对比Java标准NIO API例如你是否知道Java NIO早期版本中的Epoll空转问题以及Netty的解决方式等。
对于这些知识点,公开的深入解读已经有很多了,在学习时希望你不要一开始就被复杂的细节弄晕,可以结合实例,逐步、有针对性的进行学习。我的一个建议是,可以试着画出相应的示意图,非常有助于理解并能清晰阐述自己的看法。
今天从Netty性能的问题开始我概要地介绍了Netty框架并且以Echo Server为例对比了Netty和Java NIO在设计上的不同。但这些都仅仅是冰山的一角全面掌握还需要下非常多的功夫。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是Netty的线程模型是什么样的
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,125 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
39 谈谈常用的分布式ID的设计方案Snowflake是否受冬令时切换影响-极客时间
专栏的绝大部分主题都侧重于 Java 语言和虚拟机,基本都是单机模式下的问题,今天我会补充一个分布式相关的问题。严格来说,分布式并不算是 Java 领域,而是一个单独的大主题,但确实也会在 Java 技术岗位面试中被涉及。在准备面试时,如果有丰富的分布式系统经验当然好;如果没有,你可以选择典型问题和基础技术进行适当准备。关于分布式,我自身的实战经验也非常有限,专栏里就谈谈从理论出发的一些思考。
今天我要问你的问题是,谈谈常用的分布式 ID 的设计方案Snowflake 是否受冬令时切换影响?
典型回答
首先,我们需要明确通常的分布式 ID 定义,基本的要求包括:
全局唯一,区别于单点系统的唯一,全局是要求分布式系统内唯一。
有序性,通常都需要保证生成的 ID 是有序递增的。例如,在数据库存储等场景中,有序 ID 便于确定数据位置,往往更加高效。
目前业界的方案很多,典型方案包括:
基于数据库自增序列的实现。这种方式优缺点都非常明显,好处是简单易用,但是在扩展性和可靠性等方面存在局限性。
基于 Twitter 早期开源的Snowflake的实现以及相关改动方案。这是目前应用相对比较广泛的一种方式其结构定义你可以参考下面的示意图。
整体长度通常是 64 1 + 41 + 10+ 12 = 64适合使用 Java 语言中的 long 类型来存储。
头部是 1 位的正负标识位。
紧跟着的高位部分包含 41 位时间戳,通常使用 System.currentTimeMillis()。
后面是 10 位的 WorkerID标准定义是 5 位数据中心 + 5 位机器 ID组成了机器编号以区分不同的集群节点。
最后的 12 位就是单位毫秒内可生成的序列号数目的理论极限。
Snowflake 的官方版本是基于 Scala 语言Java 等其他语言的参考实现有很多,是一种非常简单实用的方式,具体位数的定义是可以根据分布式系统的真实场景进行修改的,并不一定要严格按照示意图中的设计。
Redis、ZooKeeper、MongoDB 等中间件,也都有各种唯一 ID 解决方案。其中一些设计也可以算作是 Snowflake 方案的变种。例如MongoDB 的ObjectId提供了一个 12 byte96 位)的 ID 定义,其中 32 位用于记录以秒为单位的时间,机器 ID 则为 24 位16 位用作进程 ID24 位随机起始的计数序列。
国内的一些大厂开源了其自身的部分分布式 ID 实现InfoQ 就曾经介绍过微信的seqsvr它采取了相对复杂的两层架构并根据社交应用的数据特点进行了针对性设计具体请参考相关代码实现。另外百度、美团等也都有开源或者分享了不同的分布式 ID 实现,都可以进行参考。
关于第二个问题Snowflake 是否受冬令时切换影响?
我认为没有影响,你可以从 Snowflake 的具体算法实现寻找答案。我们知道 Snowflake 算法的 Java 实现,大都是依赖于 System.currentTimeMillis(),这个数值代表什么呢?从 Javadoc 可以看出,它是返回当前时间和 1970 年 1 月 1 号 UTC 时间相差的毫秒数,这个数值与夏 / 冬令时并没有关系,所以并不受其影响。
考点分析
今天的问题不仅源自面试的热门考点,并且也存在着广泛的应用场景,我前面给出的回答只是一个比较精简的典型方案介绍。我建议你针对特定的方案进行深入分析,以保证在面试官可能会深入追问时能有充分准备;如果恰好在现有系统使用分布式 ID理解其设计细节是很有必要的。
涉及分布式,很多单机模式下的简单问题突然就变得复杂了,这是分布式天然的复杂性,需要从不同角度去理解适用场景、架构和细节算法,我会从下面的角度进行适当解读:
我们的业务到底需要什么样的分布式 ID除了唯一和有序还有哪些必须要考虑的要素
在实际场景中,针对典型的方案,有哪些可能的局限性或者问题,可以采取什么办法解决呢?
知识扩展
如果试图深入回答这个问题,首先需要明确业务场景的需求要点,我们到底需要一个什么样的分布式 ID
除了唯一和有序,考虑到分布式系统的功能需要,通常还会额外希望分布式 ID 保证:
有意义,或者说包含更多信息,例如时间、业务等信息。这一点和有序性要求存在一定关联,如果 ID 中包含时间本身就能保证一定程度的有序虽然并不能绝对保证。ID 中包含额外信息,在分布式数据存储等场合中,有助于进一步优化数据访问的效率。
高可用性,这是分布式系统的必然要求。前面谈到的方案中,有的是真正意义上的分布式,有得还是传统主从的思路,这一点没有绝对的对错,取决于我们业务对扩展性、性能等方面的要求。
紧凑性ID 的大小可能受到实际应用的制约,例如数据库存储往往对长 ID 不友好,太长的 ID 会降低 MySQL 等数据库索引的性能;编程语言在处理时也可能受数据类型长度限制。
在具体的生产环境中,还有可能提出对 QPS 等方面的具体要求,尤其是在国内一线互联网公司的业务规模下,更是需要考虑峰值业务场景的数量级层次需求。
第二,主流方案的优缺点分析。
对于数据库自增方案,除了实现简单,它生成的 ID 还能够保证固定步长的递增,使用很方便。
但是,因为每获取一个 ID 就会触发数据库的写请求,是一个代价高昂的操作,构建高扩展性、高性能解决方案比较复杂,性能上限明显,更不要谈扩容等场景的难度了。与此同时,保证数据库方案的高可用性也存在挑战,数据库可能发生宕机,即使采取主从热备等各种措施,也可能出现 ID 重复等问题。
实际大厂商往往是构建了多层的复合架构例如美团公开的数据库方案Leaf-Segment引入了起到缓存等作用的 Leaf 层,对数据库操作则是通过数据库中间件提供的批量操作,这样既能保证性能、扩展性,也能保证高可用。但是,这种方案对基础架构层面的要求很多,未必适合普通业务规模的需求。
与其相比Snowflake 方案的好处是算法简单,依赖也非常少,生成的序列可预测,性能也非常好,比如 Twitter 的峰值超过 10 万 /s。
但是,它也存在一定的不足,例如:
时钟偏斜问题Clock Skew。我们知道普通的计算机系统时钟并不能保证长久的一致性可能发生时钟回拨等问题这就会导致时间戳不准确进而产生重复 ID。
针对这一点Twitter 曾经在文档中建议开启NTP毕竟 Snowflake 对时间存在依赖,但是也有人提议关闭 NTP。我个人认为还是应该开启 NTP只是可以考虑将 stepback 设置为 0以禁止回调。
从设计和具体编码的角度,还有一个很有效的措施就是缓存历史时间戳,然后在序列生成之前进行检验,如果出现当前时间落后于历史时间的不合理情况,可以采取相应的动作,要么重试、等待时钟重新一致,或者就直接提示服务不可用。
另外,序列号的可预测性是把双刃剑,虽然简化了一些工程问题,但很多业务场景并不适合可预测的 ID。如果你用它作为安全令牌之类则是非常危险的很容易被黑客猜测并利用。
ID 设计阶段需要谨慎考虑暴露出的信息。例如Erlang 版本的 flake 实现基于 MAC 地址计算 WorkerID在安全敏感的领域往往是不可以这样使用的。
从理论上来说,类似 Snowflake 的方案由于时间数据位数的限制存在与2038 年问题相似的理论极限。虽然目前的系统设计考虑数十年后的问题还太早,但是理解这些可能的极限是有必要的,也许会成为面试的过程中的考察点。
如果更加深入到时钟和分布式系统时序的问题,还有与分布式 ID 相关但又有所区别的问题比如在分布式系统中不同机器的时间很可能是不一致的如何保证事件的有序性Lamport 在 1978 年的论文Time, Clocks, and the Ording of Events in a Distributed System中就有很深入的阐述有兴趣的同学可以去查找相应的翻译和解读
最后,我再补充一些当前分布式领域的面试热点,例如:
分布式事务,包括其产生原因、业务背景、主流的解决方案等。
理解CAP、BASE等理论懂得从最终一致性等角度来思考问题理解Paxos、Raft等一致性算法。
理解典型的分布式锁实现例如最常见的Redis 分布式锁。
负载均衡等分布式领域的典型算法,至少要了解主要方案的原理。
这些方面目前都已经有相对比较深入的分析,尤其是来自于一线大厂的实践经验。另外,在左耳听风专栏的“程序员练级攻略”里,提供了非常全面的分布式学习资料,感兴趣的同学可以参考。
今天我简要梳理了当前典型的分布式 ID 生成方案,并探讨了 ID 设计的一些考量,尤其是应用相对广泛的 Snowflake 的不足之处,希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是从理论上来看Snowflake 这种基于时间的算法,从形式上天然地限制了 ID 的并发生成数量,如果在极端情况下,短时间需要更多 ID有什么办法解决呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,137 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
39 谈谈常用的分布式ID的设计方案Snowflake是否受冬令时切换影响
专栏的绝大部分主题都侧重于Java语言和虚拟机基本都是单机模式下的问题今天我会补充一个分布式相关的问题。严格来说分布式并不算是Java领域而是一个单独的大主题但确实也会在Java技术岗位面试中被涉及。在准备面试时如果有丰富的分布式系统经验当然好如果没有你可以选择典型问题和基础技术进行适当准备。关于分布式我自身的实战经验也非常有限专栏里就谈谈从理论出发的一些思考。
今天我要问你的问题是谈谈常用的分布式ID的设计方案Snowflake是否受冬令时切换影响
典型回答
首先我们需要明确通常的分布式ID定义基本的要求包括
全局唯一,区别于单点系统的唯一,全局是要求分布式系统内唯一。
有序性通常都需要保证生成的ID是有序递增的。例如在数据库存储等场景中有序ID便于确定数据位置往往更加高效。
目前业界的方案很多,典型方案包括:
基于数据库自增序列的实现。这种方式优缺点都非常明显,好处是简单易用,但是在扩展性和可靠性等方面存在局限性。
基于Twitter早期开源的Snowflake的实现以及相关改动方案。这是目前应用相对比较广泛的一种方式其结构定义你可以参考下面的示意图。
整体长度通常是64 1 + 41 + 10+ 12 = 64适合使用Java语言中的long类型来存储。
头部是1位的正负标识位。
紧跟着的高位部分包含41位时间戳通常使用System.currentTimeMillis()。
后面是10位的WorkerID标准定义是5位数据中心 + 5位机器ID组成了机器编号以区分不同的集群节点。
最后的12位就是单位毫秒内可生成的序列号数目的理论极限。
Snowflake的官方版本是基于Scala语言Java等其他语言的参考实现有很多是一种非常简单实用的方式具体位数的定义是可以根据分布式系统的真实场景进行修改的并不一定要严格按照示意图中的设计。
Redis、ZooKeeper、MongoDB等中间件也都有各种唯一ID解决方案。其中一些设计也可以算作是Snowflake方案的变种。例如MongoDB的ObjectId提供了一个12 byte96位的ID定义其中32位用于记录以秒为单位的时间机器ID则为24位16位用作进程ID24位随机起始的计数序列。
国内的一些大厂开源了其自身的部分分布式ID实现InfoQ就曾经介绍过微信的seqsvr它采取了相对复杂的两层架构并根据社交应用的数据特点进行了针对性设计具体请参考相关代码实现。另外百度、美团等也都有开源或者分享了不同的分布式ID实现都可以进行参考。
关于第二个问题Snowflake是否受冬令时切换影响
我认为没有影响你可以从Snowflake的具体算法实现寻找答案。我们知道Snowflake算法的Java实现大都是依赖于System.currentTimeMillis()这个数值代表什么呢从Javadoc可以看出它是返回当前时间和1970年1月1号UTC时间相差的毫秒数这个数值与夏/冬令时并没有关系,所以并不受其影响。
考点分析
今天的问题不仅源自面试的热门考点并且也存在着广泛的应用场景我前面给出的回答只是一个比较精简的典型方案介绍。我建议你针对特定的方案进行深入分析以保证在面试官可能会深入追问时能有充分准备如果恰好在现有系统使用分布式ID理解其设计细节是很有必要的。
涉及分布式,很多单机模式下的简单问题突然就变得复杂了,这是分布式天然的复杂性,需要从不同角度去理解适用场景、架构和细节算法,我会从下面的角度进行适当解读:
我们的业务到底需要什么样的分布式ID除了唯一和有序还有哪些必须要考虑的要素
在实际场景中,针对典型的方案,有哪些可能的局限性或者问题,可以采取什么办法解决呢?
知识扩展
如果试图深入回答这个问题首先需要明确业务场景的需求要点我们到底需要一个什么样的分布式ID
除了唯一和有序考虑到分布式系统的功能需要通常还会额外希望分布式ID保证
有意义或者说包含更多信息例如时间、业务等信息。这一点和有序性要求存在一定关联如果ID中包含时间本身就能保证一定程度的有序虽然并不能绝对保证。ID中包含额外信息在分布式数据存储等场合中有助于进一步优化数据访问的效率。
高可用性,这是分布式系统的必然要求。前面谈到的方案中,有的是真正意义上的分布式,有得还是传统主从的思路,这一点没有绝对的对错,取决于我们业务对扩展性、性能等方面的要求。
紧凑性ID的大小可能受到实际应用的制约例如数据库存储往往对长ID不友好太长的ID会降低MySQL等数据库索引的性能编程语言在处理时也可能受数据类型长度限制。
在具体的生产环境中还有可能提出对QPS等方面的具体要求尤其是在国内一线互联网公司的业务规模下更是需要考虑峰值业务场景的数量级层次需求。
第二,主流方案的优缺点分析。
对于数据库自增方案除了实现简单它生成的ID还能够保证固定步长的递增使用很方便。
但是因为每获取一个ID就会触发数据库的写请求是一个代价高昂的操作构建高扩展性、高性能解决方案比较复杂性能上限明显更不要谈扩容等场景的难度了。与此同时保证数据库方案的高可用性也存在挑战数据库可能发生宕机即使采取主从热备等各种措施也可能出现ID重复等问题。
实际大厂商往往是构建了多层的复合架构例如美团公开的数据库方案Leaf-Segment引入了起到缓存等作用的Leaf层对数据库操作则是通过数据库中间件提供的批量操作这样既能保证性能、扩展性也能保证高可用。但是这种方案对基础架构层面的要求很多未必适合普通业务规模的需求。
与其相比Snowflake方案的好处是算法简单依赖也非常少生成的序列可预测性能也非常好比如Twitter的峰值超过10万/s。
但是,它也存在一定的不足,例如:
时钟偏斜问题Clock Skew。我们知道普通的计算机系统时钟并不能保证长久的一致性可能发生时钟回拨等问题这就会导致时间戳不准确进而产生重复ID。
针对这一点Twitter曾经在文档中建议开启NTP毕竟Snowflake对时间存在依赖但是也有人提议关闭NTP。我个人认为还是应该开启NTP只是可以考虑将stepback设置为0以禁止回调。
从设计和具体编码的角度,还有一个很有效的措施就是缓存历史时间戳,然后在序列生成之前进行检验,如果出现当前时间落后于历史时间的不合理情况,可以采取相应的动作,要么重试、等待时钟重新一致,或者就直接提示服务不可用。
另外序列号的可预测性是把双刃剑虽然简化了一些工程问题但很多业务场景并不适合可预测的ID。如果你用它作为安全令牌之类则是非常危险的很容易被黑客猜测并利用。
ID设计阶段需要谨慎考虑暴露出的信息。例如Erlang版本的flake实现基于MAC地址计算WorkerID在安全敏感的领域往往是不可以这样使用的。
从理论上来说类似Snowflake的方案由于时间数据位数的限制存在与2038年问题相似的理论极限。虽然目前的系统设计考虑数十年后的问题还太早但是理解这些可能的极限是有必要的也许会成为面试的过程中的考察点。
如果更加深入到时钟和分布式系统时序的问题还有与分布式ID相关但又有所区别的问题比如在分布式系统中不同机器的时间很可能是不一致的如何保证事件的有序性Lamport在1978年的论文Time, Clocks, and the Ording of Events in a Distributed System中就有很深入的阐述有兴趣的同学可以去查找相应的翻译和解读。
最后,我再补充一些当前分布式领域的面试热点,例如:
分布式事务,包括其产生原因、业务背景、主流的解决方案等。
理解CAP、BASE等理论懂得从最终一致性等角度来思考问题理解Paxos、Raft等一致性算法。
理解典型的分布式锁实现例如最常见的Redis分布式锁。
负载均衡等分布式领域的典型算法,至少要了解主要方案的原理。
这些方面目前都已经有相对比较深入的分析,尤其是来自于一线大厂的实践经验。另外,在[左耳听风专栏的“程序员练级攻略”]里,提供了非常全面的分布式学习资料,感兴趣的同学可以参考。
今天我简要梳理了当前典型的分布式ID生成方案并探讨了ID设计的一些考量尤其是应用相对广泛的Snowflake的不足之处希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是从理论上来看Snowflake这种基于时间的算法从形式上天然地限制了ID的并发生成数量如果在极端情况下短时间需要更多ID有什么办法解决呢
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@@ -0,0 +1,85 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
周末福利 一份Java工程师必读书单-极客时间
你好,我是杨晓峰。今天这期周末福利,我整理了几本自己在学习和使用 Java 时用过的参考书,把它们分享与你。在专栏里,有不少同学留言让我推荐一些参考书。另外,我认为,书是个好的系统化知识来源,但更多提高还是来源于实践阅读源码、技术交流等,毕竟书籍也很难完全跟上技术和架构的变革,另外我也尽量缩减了书单的长度。
关于夯实 Java 编程基础,我推荐 Bruce Eckel 的《Java 编程思想》Thinking in Java非常有名的经典书籍。这本书的特点是不仅仅介绍 Java 编程的基础知识点,也会思考编程中的各种选择与判断,包括穿插设计模式的使用,作者从理论到实践意义从不同的角度进行探讨,构建稳固的 Java 编程知识体系。
当然这本书也有不足之处,毕竟每个人的基础不太一样,如果你完全没有 Java 编程基础,也可以考虑其他的参考书,例如 《Java 核心技术》。
另外这两本书的部分内容已经多少有点过时了尤其是《Java 编程思想》。例如,目前很少会需要学习 Java 桌面图形类库等,较新的语法和 API 当然也没有包含,我的建议是尽管忽略过时内容,适当补充 Java 新技术的学习。
提到经典,自然也少不了 《Effective Java》这本书的英文第三版已经在国内上市涵盖了 Java 7 到 Java 9 的各种新特性。严格来说这本书不算是一本基础书籍但当你有一定基础后还是非常建议通读一下的。关于这本书的阅读我的建议是边学习边回顾在吸收书中的经验时多去想想自己在实际应用中是如何处理的。虽然《Effective Java》的具体章节可能是从某个点出发但可以说都是对 Java、JVM、面向对象等各种知识的综合运用对于设计和实现高质量的代码很有帮助。
《Head First 设计模式》 这本书就不用我再费笔墨去介绍了吧,能把设计模式用这种轻松的形式展现本身已经十分不易了,章节之间的联系让你可以反复加深印象,加上生动的表达方式和丰富的习题更容易沉浸其中。
这本书非常适合对面向对象和设计模式基础有限的同学。设计模式不是银弹,实践中也莫要为了模式而模式,掌握典型模式,能够举一反三就很好了,就当作是程序员之间沟通的“方言”。
谈过了 Java 基础,接下来聊聊并发和虚拟机的参考书。
《Java 并发编程实战》,作者全是响当当的人物,比如 Brian Goetz我多次在专栏里引用他的观点众多强力作者也保证了书的质量。抛开作者光环这本书的内容全部建立在理论之上先讲清道理再谈实践可以真正让你知其然也知其所以然。这本书更加侧重并发编程中有哪些问题如何来深刻地理解和定义问题如何利用可靠的手段指导工程实践并没有过分纠结于并发类库的源码层面。
这本书的我的学习建议是,尽量充分利用其中提供的样例代码,结合自己的业务实践去深入学习,毕竟这本书的内容有些偏理论,可能并不适合你快速掌握所谓并发“核心”技术。
关于 JVM 的学习,不用我多说了吧,看过专栏的同学肯定都知道,我经常推荐周志明的《深入理解 Java 虚拟机》,可以说是国内最好的 JVM 书籍之一。
我这里并没有单独推荐类似 GC 算法等书籍,它们对于大多数 Java 工程师的价值也许有限。
关于性能优化,我推荐 Charlie Hunt 和 Binu John 所著的《Java 性能优化权威指南》Java Performance也是我上次在直播时向大家推荐的。Java 之父 James Gosling也力荐这本参考书。
但这本书也存在着不足,里面过于偏重 Solaris 等商业操作系统和相关工具,我建议你在阅读的时候,尽量体会其思路和原理,更加侧重于 Linux 等主流开放平台。
还有一些如开源软件和互联网架构相关的图书可以作为扩展阅读,你可以参考下面这几本。
《Spring 实战》
可以说 Spring 等相关框架已经成为业务开发的事实标准,系统性地掌握 Spring 框架的设计和实践,是必需的技能之一。
《Netty 实战》
Netty 在性能、可扩展性等方面的突出表现,已经得到充分验证,作为基础的通信框架,已经广泛应用在各种互联网架构、游戏等领域,甚至可以说,如果没有仔细分析过 Netty对 NIO 等方面的理解很可能还在很肤浅的阶段。
《Cloud Native Java》
Java 应用程序架构处于飞快的演进之中,微服务等新的架构应用越来越广泛,即使未必是使用 Spring Boot、Spring Cloud 等框架,但是系统的学习其设计思想和实践技术,绝对是有必要的。当然如果你在实践中使用 Dubbo 等框架,也可以选择相关书籍。
前沿领域的变化非常快,很多风靡一时的开源软件,在实践中逐渐被证明存在各种弊端,或者厂商停止维护。所以这部分的学习,我建议不要盲目追新,最好是关注于分布式设计中的问题和解决的思路,做到触类旁通,并且注重书籍之外的学习渠道。
下面两本并不算是 Java 书籍,但 Java 程序员进阶少不了对互联网主流架构的学习,了解分布式架构、缓存、消息中间件等令人眼花缭乱的技术,对于有志于成为架构师的 Java 工程师来说非常有帮助。
《大型分布式网站架构设计与实践》
这本书总结了作者在构建安全、可稳定性、高扩展性、高并发的分布式网站方面的心得。
《深入分布式缓存:从原理到实践》
这本书融合了原理、架构和一线互联网公司的案例实践,值得参考。
下面给入选精选留言的同学送出 15 元学习奖励礼券。专栏即将进入尾声,希望所有订阅的同学能够坚持到底,也欢迎大家留言分享自己学习或面试的心得体会。

View File

@@ -0,0 +1,87 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
周末福利 谈谈我对Java学习和面试的看法-极客时间
你好我是杨晓峰。今天是周末我们稍微放松一下来聊聊“Java 核心技术”之外的内容,正好也借这个机会,兑现一下送出学习奖励礼券的承诺。我在每一讲后面都留下了一道思考题,希望你通过学习,结合自身工作实际,能够认真思考一下这些问题,一方面起到检验学习效果的作用,另一方面可以查漏补缺,思考一下这些平时容易被忽略的面试考察点。我并没有给出这些思考题的答案,希望你通过专栏学习或者查阅其他资料进行独立思考,将自己思考的答案写在留言区与我和其他同学一起交流,这也是提升自己重要的方法之一。
截止到今天,专栏已经更新了 15 讲,走完了基础模块正式进入进阶模块。现在也正是一个很好的时机停下来回顾一下基础部分的知识,为后面进阶的并发内容打好基础。在这里,我也分享一下我对 Java 学习和面试的看法,希望对你有所帮助。
首先,有同学反馈说专栏有的内容看不懂。我在准备专栏文章的时候对一些同学的基础把握不太准确,后面的文章我进行了调整,将重点技术概念进行讲解,并为其他术语添加链接。
再来说说这种情况,有人总觉得 Java 基础知识都已经被讲烂了,还有什么可学的?
对于基础知识的掌握,有的同学经常是“知其然而不知其所以然”, 看到几个名词听说过就以为自己掌握了,其实不然。至少,我认为应该能够做到将自己“掌握”的东西,准确地表达出来。
爱因斯坦曾经说过,“如果你不能把它简单地解释出来,那说明你还没有很好地理解它”。了解 - 掌握 - 精通,这是我们对事物掌握的一个循序渐进的过程。从自己觉得似乎懂了,到能够说明白,再到能够自然地运用它,甚至触类旁通,这是不断提高的过程。
在专栏学习中,如果有些术语很陌生,那么了解它就达到了学习目的,如果能够理解透彻达到掌握的程度当然更好。乐观点来看,反正都是有收获,也完全不必过分担心。
从学习技巧的角度,每个人都有自己的习惯,我个人喜欢动手实践以及与人进行交流。
动手实践是必要一步,如果连上手操作都不肯,你会发现自己的理解很难有深度。
在交流的过程中你会发现,很多似是而非的理解,竟然在试图组织语言的时候,突然就想明白了,而且别人的观点也验证了自己的判断。技术领域尤其如此,把自己的理解整理成文字,输出、交流是个非常好的提高方法,甚至我认为这是技术工作者成长的必经之路。
再来聊聊针对技术底层,我们是否有必要去阅读源代码?
阅读源代码当然是个好习惯,理解高质量的代码,对于提高我们自己的分析、设计等能力至关重要。
根据实践统计,工程师实际工作中,阅读代码的时间其实大大超过写代码的时间,这意味着阅读、总结能力,会直接影响我们的工作效率!这东西有没有捷径呢,也许吧,我的心得是:“无他,但手熟尔”。
参考别人的架构、实现,分析其历史上掉过的坑,这是天然的好材料,具体阅读时可以从其修正过的问题等角度入手。
现代软件工程,节奏越来越快,需求复杂而多变,越来越凸显出白盒方式的重要性。快速定位问题往往需要黑盒结合白盒能力,对内部一无所知,可能就没有思路。与此同时,通用平台、开源框架,不见得能够非常符合自己的业务需求,往往只有深入源代码层面进行定制或者自研,才能实现。我认为这也是软件工程师地位不断提高的原因之一。
那么,源代码需要理解到什么程度呢?
对于底层技术,这个确实是比较有争议的问题,我个人并不觉得什么东西都要理解底层,懂当然好,但不能代表一切,毕竟知识和能力是有区别的,当然我们也要尊重面试官的要求。我个人认为,不是所有做 Java 开发的人,都需要读 JVM 源代码,虽然我在专栏中提供了一些底层源代码解读,但也只是希望真的有兴趣、有需要的工程师跟进学习。对于大多数开发人员,了解一些源代码,至少不会在面试问到的时候完全没有准备。
关于阅读源代码和理解底层,我有些建议:
带着问题和明确目的去阅读,比如,以 debug 某个问题的角度,结合实践去验证,让自己能够感到收获,既加深理解,也有实际帮助,激励我们坚持下来。
一定要有输出,至少要写下来,整理心得,交流、验证、提高。这和我们日常工作是类似的,千万不要做了好长一段时间后和领导说,没什么结论。
大家大都是工程师不是科学家软件开发中需要分清表象、行为behavior还是约定specification。喜欢源代码、底层是好的但是一定要区分其到底是实现细节还是规范的承诺因为如果我们的程序依赖于表现很有可能带来未来维护的问题。
我前面提到了白盒方式的重要性,但是,需要慎重决定对内部的依赖,分清是 Hack 还是 Solution。出来混总是要还的如果以某种 hack 方式解决问题,临时性的当然可以,长久会积累并成为升级的障碍,甚至堆积起来愈演愈烈。比如说,我在实验 Cassandra 的时候,发现它在并发部分引用了 Unsafe.monitorEnter()/moniterExit(),这会导致它无法平滑运行在新版的 JDK 上,因为相应内部 API 被移除了,比较幸运的是这个东西有公共 API 可以替代。
最后谈谈我在面试时会看中候选人的哪些素质和能力。
结合我在实际工作中的切身体会,面试时有几个方面我会特别在乎:
技术素养好,能够进行深度思考,而不是跳脱地夸夸其谈,所以我喜欢问人家最擅长的东西,如果在最擅长的领域尚且不能仔细思考,怎么能保证在下一份工作中踏实研究呢。当然这种思考,并不是说非要死扣底层和细节,能够看出业务中平凡事情背后的工程意义,同样是不错的。毕竟,除了特别的岗位,大多数任务,如果有良好的技术素养和工作热情,再配合一定经验,基本也就能够保证胜任了。
职业精神,是否表现出认真对待每一个任务。我们是职场打拼的专业人士,不是幼儿园被呵护的小朋友,如果有人太挑活儿,团队往往就无法做到基本的公平。有经验的管理角色,大多是把自己的管理精力用在团队的正面建设,而不是把精力浪费在拖团队后腿的人身上,难以协作的人,没有人会喜欢。有人说你的职业高度取决于你“填坑”的能力,我觉得很有道理。现实工作中很少有理想化的完美任务,既目标清晰又有挑战,恰好还是我擅长,这种任务不多见。能够主动地从不清晰中找出清晰,切实地解决问题,是非常重要的能力。
是否 hands-on是否主动。我一般不要求当前需要的方面一定是很 hands-on但至少要表现出能够做到。
下面放出中奖名单和精选留言,送出 15 元学习奖励礼券希望我的《Java 核心技术 36 讲》不仅能带你走进大厂 Java 面试场景,还能帮你温故知新基础知识,构建你的 Java 知识体系。也欢迎你在这里与我交流面试、学习方面的困惑或心得,一起畅所欲言、共同进步。

View File

@@ -0,0 +1,86 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
周末福利 谈谈我对Java学习和面试的看法
你好我是杨晓峰。今天是周末我们稍微放松一下来聊聊“Java核心技术”之外的内容正好也借这个机会兑现一下送出学习奖励礼券的承诺。我在每一讲后面都留下了一道思考题希望你通过学习结合自身工作实际能够认真思考一下这些问题一方面起到检验学习效果的作用另一方面可以查漏补缺思考一下这些平时容易被忽略的面试考察点。我并没有给出这些思考题的答案希望你通过专栏学习或者查阅其他资料进行独立思考将自己思考的答案写在留言区与我和其他同学一起交流这也是提升自己重要的方法之一。
截止到今天专栏已经更新了15讲走完了基础模块正式进入进阶模块。现在也正是一个很好的时机停下来回顾一下基础部分的知识为后面进阶的并发内容打好基础。在这里我也分享一下我对Java学习和面试的看法希望对你有所帮助。
首先,有同学反馈说专栏有的内容看不懂。我在准备专栏文章的时候对一些同学的基础把握不太准确,后面的文章我进行了调整,将重点技术概念进行讲解,并为其他术语添加链接。
再来说说这种情况有人总觉得Java基础知识都已经被讲烂了还有什么可学的
对于基础知识的掌握,有的同学经常是“知其然而不知其所以然”, 看到几个名词听说过就以为自己掌握了,其实不然。至少,我认为应该能够做到将自己“掌握”的东西,准确地表达出来。
爱因斯坦曾经说过,“如果你不能把它简单地解释出来,那说明你还没有很好地理解它”。了解-掌握-精通,这是我们对事物掌握的一个循序渐进的过程。从自己觉得似乎懂了,到能够说明白,再到能够自然地运用它,甚至触类旁通,这是不断提高的过程。
在专栏学习中,如果有些术语很陌生,那么了解它就达到了学习目的,如果能够理解透彻达到掌握的程度当然更好。乐观点来看,反正都是有收获,也完全不必过分担心。
从学习技巧的角度,每个人都有自己的习惯,我个人喜欢动手实践以及与人进行交流。
动手实践是必要一步,如果连上手操作都不肯,你会发现自己的理解很难有深度。
在交流的过程中你会发现,很多似是而非的理解,竟然在试图组织语言的时候,突然就想明白了,而且别人的观点也验证了自己的判断。技术领域尤其如此,把自己的理解整理成文字,输出、交流是个非常好的提高方法,甚至我认为这是技术工作者成长的必经之路。
再来聊聊针对技术底层,我们是否有必要去阅读源代码?
阅读源代码当然是个好习惯,理解高质量的代码,对于提高我们自己的分析、设计等能力至关重要。
根据实践统计,工程师实际工作中,阅读代码的时间其实大大超过写代码的时间,这意味着阅读、总结能力,会直接影响我们的工作效率!这东西有没有捷径呢,也许吧,我的心得是:“无他,但手熟尔”。
参考别人的架构、实现,分析其历史上掉过的坑,这是天然的好材料,具体阅读时可以从其修正过的问题等角度入手。
现代软件工程,节奏越来越快,需求复杂而多变,越来越凸显出白盒方式的重要性。快速定位问题往往需要黑盒结合白盒能力,对内部一无所知,可能就没有思路。与此同时,通用平台、开源框架,不见得能够非常符合自己的业务需求,往往只有深入源代码层面进行定制或者自研,才能实现。我认为这也是软件工程师地位不断提高的原因之一。
那么,源代码需要理解到什么程度呢?-
对于底层技术这个确实是比较有争议的问题我个人并不觉得什么东西都要理解底层懂当然好但不能代表一切毕竟知识和能力是有区别的当然我们也要尊重面试官的要求。我个人认为不是所有做Java开发的人都需要读JVM源代码虽然我在专栏中提供了一些底层源代码解读但也只是希望真的有兴趣、有需要的工程师跟进学习。对于大多数开发人员了解一些源代码至少不会在面试问到的时候完全没有准备。
关于阅读源代码和理解底层,我有些建议:
带着问题和明确目的去阅读比如以debug某个问题的角度结合实践去验证让自己能够感到收获既加深理解也有实际帮助激励我们坚持下来。
一定要有输出,至少要写下来,整理心得,交流、验证、提高。这和我们日常工作是类似的,千万不要做了好长一段时间后和领导说,没什么结论。
大家大都是工程师不是科学家软件开发中需要分清表象、行为behavior还是约定specification。喜欢源代码、底层是好的但是一定要区分其到底是实现细节还是规范的承诺因为如果我们的程序依赖于表现很有可能带来未来维护的问题。
我前面提到了白盒方式的重要性但是需要慎重决定对内部的依赖分清是Hack还是Solution。出来混总是要还的如果以某种hack方式解决问题临时性的当然可以长久会积累并成为升级的障碍甚至堆积起来愈演愈烈。比如说我在实验Cassandra的时候发现它在并发部分引用了Unsafe.monitorEnter()/moniterExit()这会导致它无法平滑运行在新版的JDK上因为相应内部API被移除了比较幸运的是这个东西有公共API可以替代。
最后谈谈我在面试时会看中候选人的哪些素质和能力。
结合我在实际工作中的切身体会,面试时有几个方面我会特别在乎:
技术素养好,能够进行深度思考,而不是跳脱地夸夸其谈,所以我喜欢问人家最擅长的东西,如果在最擅长的领域尚且不能仔细思考,怎么能保证在下一份工作中踏实研究呢。当然这种思考,并不是说非要死扣底层和细节,能够看出业务中平凡事情背后的工程意义,同样是不错的。毕竟,除了特别的岗位,大多数任务,如果有良好的技术素养和工作热情,再配合一定经验,基本也就能够保证胜任了。
职业精神,是否表现出认真对待每一个任务。我们是职场打拼的专业人士,不是幼儿园被呵护的小朋友,如果有人太挑活儿,团队往往就无法做到基本的公平。有经验的管理角色,大多是把自己的管理精力用在团队的正面建设,而不是把精力浪费在拖团队后腿的人身上,难以协作的人,没有人会喜欢。有人说你的职业高度取决于你“填坑”的能力,我觉得很有道理。现实工作中很少有理想化的完美任务,既目标清晰又有挑战,恰好还是我擅长,这种任务不多见。能够主动地从不清晰中找出清晰,切实地解决问题,是非常重要的能力。
是否hands-on是否主动。我一般不要求当前需要的方面一定是很hands-on但至少要表现出能够做到。
下面放出中奖名单和精选留言送出15元学习奖励礼券希望我的《Java核心技术36讲》不仅能带你走进大厂Java面试场景还能帮你温故知新基础知识构建你的Java知识体系。也欢迎你在这里与我交流面试、学习方面的困惑或心得一起畅所欲言、共同进步。
祝贺石头狮子、Woj、kursk.ye、Miaozhe、肖一林、曹铮、雷霹雳的爸爸、vash_ace、Walter也要感谢I am a psycho、magict4、李林、Woong、L.B.Q.Y指出我文稿中的疏漏一并送出学习奖励礼券。

View File

@@ -0,0 +1,33 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 技术没有终点-极客时间
James Governor 曾经说过:“这么多年最大的体会就是 Java is Dead is Dead”。我也是同样的感受Java 已经一再被证明它在业界中不可替代的作用。选择 Java 语言的开发者是幸运的,经历了如此漫长的发展后,我们依然有幸可以见证“廉颇老矣”的 Java 再次加速成长“焕发青春”,因此非常值得你深入进去。
时间过得飞快,专栏已经到了尾声,非常感谢你的支持。这一路走来,真心体会到专栏创作的不易,经常在深夜反复斟酌文章的难易程度,踌躇如何让内容既不失深入性,又要通俗易懂,尽量覆盖更多知识点。同时,也有深深的不舍,你的留言和反馈,迸发出了知识的火花,也让我进一步感受到了专栏的价值所在。
回想最初我在专栏内容设计时,希望更侧重于 Java 语言和虚拟机的基础领域,因为这些内容在飞速变化的世界中更加具备长久价值。
在专栏里,我与你一起重温了 Java 语言和虚拟机那些“黑魔法”并通过探讨其背后的故事尽我所能帮你达到“知其所以然”和体系化的目标。通过专栏的学习相信面试官在考察并发、JVM 等内部结构和机制的时候,你一定能做到胸有成竹。如果我的专栏,对你在日常软件设计或者问题诊断时还能有所帮助,就再好不过了。
在这里,同为工程师的我,想和同样走在这条道路上的同学,聊聊我对工程师职业发展的看法,希望对你有参考意义。
Easy is cheap在平时工作中技术人免不了要构建一个广泛的知识体系但终归是要克制住诱惑将某个领域做到精深。水桶装水量取决于最短板但是大多数情况下我们在工作中获取的回报更多来源于自身的长处甚至某种程度上还决定了我们是拥有自己选择的自由还是疲于奔命毕竟我们每个人的体力、精力是有现实的上限的。
在工作岗位上,从初级到高级工程师成长的过程中,最基本的一个变化就是,我们的角色会逐渐发生从 how 到 what 的转换。工作初期,我们更多是承担被指派的任务,重点是搞清楚怎么做,但是当我们逐步成长起来,更多的是要看清楚什么是最重要的。
还好在极客时间的专栏里,我并不是你的老师,而是和你在一起交流的工程师,交流促进思考,沟通产生价值,技术人永远不要羞于表达自己的观点,请你坚持独立思考。
对于刚刚才订阅专栏的新同学来说,可能你打开的第一篇文章就是结束语,不过不用担心,我会一直在这里解答你学习过程中的疑惑,我给你的专栏学习建议是:注重实践和项目推动,确保结果输出,仅仅把专栏看作是个参照物,找到自己的技术道路。
对于从开始就伴随专栏一路走来的老同学,我想对你说:技术没有终点,感谢你的一路陪伴,也希望我们共同的努力能够带来丰富的收获。
最后,如果你对 Java 领域的前沿技术感兴趣也欢迎关注我的公众号“xiaofeya”我会不定期更新非常不定期。
讲到这里,专栏的结束并不代表你将止步于此,而是应该通过前面的学习,把专栏的结束当作新的开始,不管过去你是否从中掌握了新的知识或是提升了视野,希望你能以一种全新的状态重新出发,继续勇攀技术的高峰。

View File

@@ -0,0 +1,33 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 技术没有终点
James Governor曾经说过“这么多年最大的体会就是Java is Dead is Dead”。我也是同样的感受Java已经一再被证明它在业界中不可替代的作用。选择Java语言的开发者是幸运的经历了如此漫长的发展后我们依然有幸可以见证“廉颇老矣”的Java再次加速成长“焕发青春”因此非常值得你深入进去。
时间过得飞快,专栏已经到了尾声,非常感谢你的支持。这一路走来,真心体会到专栏创作的不易,经常在深夜反复斟酌文章的难易程度,踌躇如何让内容既不失深入性,又要通俗易懂,尽量覆盖更多知识点。同时,也有深深的不舍,你的留言和反馈,迸发出了知识的火花,也让我进一步感受到了专栏的价值所在。
回想最初我在专栏内容设计时希望更侧重于Java语言和虚拟机的基础领域因为这些内容在飞速变化的世界中更加具备长久价值。
在专栏里我与你一起重温了Java语言和虚拟机那些“黑魔法”并通过探讨其背后的故事尽我所能帮你达到“知其所以然”和体系化的目标。通过专栏的学习相信面试官在考察并发、JVM等内部结构和机制的时候你一定能做到胸有成竹。如果我的专栏对你在日常软件设计或者问题诊断时还能有所帮助就再好不过了。
在这里,同为工程师的我,想和同样走在这条道路上的同学,聊聊我对工程师职业发展的看法,希望对你有参考意义。
Easy is cheap在平时工作中技术人免不了要构建一个广泛的知识体系但终归是要克制住诱惑将某个领域做到精深。水桶装水量取决于最短板但是大多数情况下我们在工作中获取的回报更多来源于自身的长处甚至某种程度上还决定了我们是拥有自己选择的自由还是疲于奔命毕竟我们每个人的体力、精力是有现实的上限的。
在工作岗位上从初级到高级工程师成长的过程中最基本的一个变化就是我们的角色会逐渐发生从how到what的转换。工作初期我们更多是承担被指派的任务重点是搞清楚怎么做但是当我们逐步成长起来更多的是要看清楚什么是最重要的。
还好在极客时间的专栏里,我并不是你的老师,而是和你在一起交流的工程师,交流促进思考,沟通产生价值,技术人永远不要羞于表达自己的观点,请你坚持独立思考。
对于刚刚才订阅专栏的新同学来说,可能你打开的第一篇文章就是结束语,不过不用担心,我会一直在这里解答你学习过程中的疑惑,我给你的专栏学习建议是:注重实践和项目推动,确保结果输出,仅仅把专栏看作是个参照物,找到自己的技术道路。
对于从开始就伴随专栏一路走来的老同学,我想对你说:技术没有终点,感谢你的一路陪伴,也希望我们共同的努力能够带来丰富的收获。
最后如果你对Java领域的前沿技术感兴趣也欢迎关注我的公众号“xiaofeya”我会不定期更新非常不定期。
讲到这里,专栏的结束并不代表你将止步于此,而是应该通过前面的学习,把专栏的结束当作新的开始,不管过去你是否从中掌握了新的知识或是提升了视野,希望你能以一种全新的状态重新出发,继续勇攀技术的高峰。