写点什么

JVM- 技术专题 - 虚拟机知识遗漏盘点

发布于: 2021 年 04 月 24 日
JVM-技术专题-虚拟机知识遗漏盘点

JVM 垃圾回收-五个建议

前提概要

早有消息声称 Java 9 即将发布,其中比较值得关注的是 G1(“Garbage-First”)垃圾收集器将成为 HotSpot JVM 的默认收集器。从串行收集器到 CMS 收集器,在整个生命周期中 JVM 已历经多代 GC 的实现和更新,随着垃圾收集器的持续发展,每一代都会进行改善和提高。在串行收集器之后的并行收集器利用多核机器强大的计算能力,实现了垃圾收集多线程。


CMS(Concurrent Mark-Sweep)收集器,将收集分为多个阶段执行,允许在应用线程运行同时进行大量的收集,大大降低了“stop-the-world”全局停顿的出现频率。G1 在 JVM 上加入了大量堆和可预测的均匀停顿,有效地提升了性能。


尽管 GC 不断在完善,其致命弱点还是一样:多余的和不可预知的对象分配。但本文中提出了一些高效的长期实用的建议,不管你选择哪种垃圾收集器,都可以帮助你降低 GC 开销。

建议 1

预测收集能力所有的 Java 标准集合和大多数自定义的扩展实现(如 Trove 和谷歌的 Guava),都会使用底层数组(无论基于原始或基于对象)。数据的长度一旦分配后,数组就不可变了,所以在许多情况下,为集合增加项目可能会导致老的底层数组被删除,然后需要重新分配一个更大的数组来替代。


大多数的集合实现都尝试在集合没有被设置为预期大小时,还能对重分配过程进行优化,并降低其开销。但是,最好的结果还是在构造集合时就设置成预期大小。

让我们看一下下面这个简单的例子:

public static List reverse(List<? extends T> list){  List result  = new ArrayList();	for(int i = list.size() -1; i >=0; i--) {	    result.add(list.get(i));	}  return result;}
复制代码

以上方法分配了一个新的数组,再将另一个列表的项目填充其中,但只能按倒序填充。

但是,难就难在如何优化增加项目到新列表这一步骤。每次添加后,该列表还需确保其底层数组有足够的空槽能装下新项目。如果能装下,它就会直接在下一个空槽中存储新项目;但如果空间不够,它就会重新分配一个底层数组,将旧数组的内容复制到新数组中,然后再添加新项目。这一过程会导致分配的多个数组都会占据内存,直到 GC 最后来回收。

所以,我们可以在构建时告知数组需容纳多少个项目,重构后的代码如下:

public static List reverse(List<? extends T> list){	List result =new ArrayList(list.size());	for(int i = list.size() -1; i >=0; i--) {    result.add(list.get(i));	}	  return result;}
复制代码

这样一来,可以保证 ArrayList 构造函数在最初配置时就能容纳下 list.size()个项目,这意味着它不需要再在迭代中重新分配内存。Guava 的集合类则更加先进,允许我们用一个确切数量或估计值来初始化集合

List result = Lists.newArrayListWithCapacity(list.size());//第一行代码是我们知道有多少项目需要存储的情况List result = Lists.newArrayListWithExpectedSize(list.size());//第二行会分配一些多余填充以适应预估误差
复制代码

建议 2

直接用处理流当处理数据流时,如从文件中读取数据或从网上下载数据,例如,我们通常可以从数据流中有所发现。


byte[] fileData = readFileToByteArray(newFile("myfile.txt"));
复制代码


由此产生的字节数组可以被解析为 XML 文档、JSON 对象或协议缓冲消息,来命名一些常用选项。

当处理大型或未知大小的文件时,这个想法则不适用了,因为当 JVM 无法分配文件大小的缓冲区时,则会出现 OutOfMemoryErrors 错误。但是,即使数据大小看似能管理,当涉及到垃圾回收时,上述模式仍会造成大量开销,因为它在堆上分配了相当大的 blob 来容纳文件数据。

更好的处理方式是使用合适的 InputStream(本例中是 FileInputStream),并直接将其送到分析器,而不是提前将整个文件读到字节数组中。所有主要库会将 API 直接暴露给解析流,例如:


FileInputStream fis =new FileInputStream(fileName);MyProtoBufMessage msg = MyProtoBufMessage.parseFrom(fis);
复制代码


建议 3

使用不可变对象不变性有诸多优势,但有一个优势却极少被重视,那就是不变性对垃圾回收的影响。

不可变对象是指对象一旦创建后,其字段(本例中指非原始字段)将无法被修改。例如:


public class ObjectPair{private final Object first;
private final Object second;
public ObjectPair(Object first, Object second){
this.first = first;
this.second = second;
}
public Object getFirst(){
return first;
}
public Object getSecond(){
return second;
}}
复制代码


实例化上面类的结果为不可变对象——所有的字段一旦标记后则不能再被修改。

不变性意味着在构造容器完成之前,由不可变容器引用的所有对象都已经创建。在 GC 看来:容器会和其最新的新生代保持一致。这意味着当对新生代(young generations)执行垃圾回收周期时,GC 可以跳过老年代(older generations)中的不可变对象,因为它知道不可变对象不能引用新生代的任何内容。

越少对象扫描意味着需扫描的内存页越少,而越少的内存页扫描意味着 GC 周期越短,同时也预示着更短的 GC 停顿和更好的整体吞吐量。

建议 4:

慎用字符串连接字符串可能是任何基于 JVM 的应用中最普遍的非原始数据结构。但是,其隐含重量和使用便利性使得它们成为应用内存变大的罪魁祸首。很明显,问题不在于被内联和拘留的文字字符串,而在于字符串在运行时被分配和构建。接下来看看构建动态字符串的简单示例:

public static String toString(T[] array){	String result ="[";	for(int i =0; i < array.length; i++) {    result += (array[i] == array ? "this" : array[i]);    if(i < array.length -1) {        result +=", ";    }	}	result +="]";	return result;}
复制代码


获取数组并返回它的字符串表示是一个很不错的方法,但这也正是对象分配的问题所在。

要看到其背后所有的语法糖并不容易,但真正的幕后场景应该是这样:

   public static String toString(T[] array){   String result ="[";
for(inti =0; i < array.length; i++) {
StringBuilder sb1 =new StringBuilder(result);
sb1.append(array[i] == array ?"this": array[i]);
result = sb1.toString();
if(i < array.length -1) {
StringBuilder sb2 =new StringBuilder(result);
sb2.append(", ");
result = sb2.toString();
}
}
StringBuilder sb3 =new StringBuilder(result);
sb3.append("]");
result = sb3.toString();
return result;}

复制代码

字符串是不可变的,所以在其连接时并没有被修改,而是依次分配新的字符串。此外,编译器利用标准 StringBuilder 类来执行的这些链接。这就导致了双重麻烦,在每次循环迭代时,我们得到(1)隐式分配临时字符串,(2)隐式分配临时的 StringBuilder 对象来帮助我们构建最终结果。

避免上述问题的最佳方法是明确使用 StringBuilder 并直接附加给它,而不是使用略幼稚的串联运算符(“+”)。所以应该是这样:


public static String toString(T[] array){
StringBuilder sb =new StringBuilder("[");
for(int i =0; i < array.length; i++) {
sb.append(array[i] == array ? "this": array[i]);
if(i < array.length -1) {
sb.append(", ");
}
}
sb.append("]");
return sb.toString();}
复制代码


此时,在方法开始时我们只分配了 StringBuilder。从这一点来看,所有的字符串和列表项都会被添加到唯一的 StringBuilder 中,最终只调用一次 toString 方法转换成字符串,然后返回结果。


建议 5


使用专门的原始集合 Java 的标准库非常方便且通用,支持使用集合绑定半静态类型。例如,如果要用一组字符串(Set<String>),或一对字符串映射到字符串列表(Map<Pair, List<String>>),直接利用标准库会非常方便。


  • 事实上,问题之所以出现是因为我们想把 double 类型的值放在 int 类型的 list 集合或 map 映射中。由于泛型不能调用原始集合,则可以用包装类型代替,所以放弃 List<int>而使用 List<Integer>更好。

  • 但其实这非常浪费,Integer 本身就是一个完备对象,由 12 字节的对象头和内部 4 字节的整数字段组合而成,加起来每个 Integer 对象占 16 个字节,这是同样大小的基类 int 类型长度的 4 倍!然而,更大的问题是所有这些 Integer 实际上都是垃圾回收过程中的对象实例。


为了解决这个问题,使用优秀 Trove 集合库。Trove 放弃了一些(但不是全部)支持专业高效内存的原始集合的泛型。例如,不用浪费的 Map,而用专门的原始集合 TintDoubleMap 来替代更好:


TIntDoubleMap map =newTIntDoubleHashMap();
map.put(5,7.0);
map.put(-1,9.999);
...
复制代码


Trove 底层实现了原始数组的使用,所以在操作集合时没有装箱(int -> Integer)或拆箱(Integer -> int)发生,因此也不会将对象存储在基类中。


结语随着垃圾收集器不断进步,以及实时优化和 JIT 编译器变得更加智能,作为开发者的我们,可以越来越少地操心代码的 GC 友好性。尽管如此,无论 G1 有多先进,在提高 JVM 方面,我们还有许多问题需要不断探索和实践,百尺竿头仍需更进一步。



JVM 参数的配置优化 Tips


  • MinHeapFreeRadio 占用 heap 总量可空闲的比率 40(伸缩性+资源利用)

  • MaxHeapFreeRadio 占用 heap 总量可空闲的比率 70(伸缩性+资源利用)


JVM 参数设置-server 放入第一位,jdk server 版,默认为 client 版本启动,但是会导致启动变慢,但是采用的是 C2 编译器,和优化,以及相关参数信息的提升等。(侧重于优化代码和编译优化机制,以及数量很大)。


-Xms/-Xmx 一般这两个值设置为大小相同,因为如果相同则可以避免扩容的节点操作,减少扩容的操作所带来的性能消耗。避免内存的忽高忽低;-Xms 默认是物理内存的 1/64,-Xmx 默认是物理内存的 1/4


说明:设置-Xms、-Xmx 相等以避免每次 GC 后调整堆的大小,因为默认空余堆内存大小 40%(MinHeapFreeRatio),JVM 会增大堆至-Xmx 数值大小,当大于默认空余堆大小(MaxHeapFreeRatio),JVM 会缩小到-Xms 的数值大小参数。


-Xmn 年轻代内存的分配大小,此处是(eden+2*Surivor 区的大小)默认也是属于物理内存的 1/64


-Xss stack 栈空间的大小操作,设置 Stack 的大小信息,jdk1.5- 是 256k,jdk5+ 1M


-XX:+AggressiveOpts 自带魔法属性:将最新的 JDK 的优化后的系特性自动注入。


-XX:+UseBiasedLocking 启动一个优化了的线程锁,用于高并发访问很重要,太多的请求忙不过来他自动优化,对于各个长短不一的请求,出现阻塞,排队现象,他自己优化。


-XX:PermSize/-XX:MaxPermSize 设置永久区的大小操作配置 1/641/4


-XX:MetaSpaceSize/-XX:MaxMetaSpaceSize 设置元数据空间相关的配置 1/64,1/4


-XX:+DisableExplictGC 禁用 System.gc()方法,因为 System.gc()会造成相关的 FullGC


-XX:NewSize/-XX:MaxNewSize:最好不要手动设置


-XX:MaxTenuringThreshold:设置进入养老区的年龄阈值,可以适当调整一下


-XX:+UseConcMarkSweepGC 使用 CMS 标记清除操作 GC 回收器


-XX:+UseParNewGC 使用新生区的并行操作回收器


标记清除法(Mark-Sweep)


标记清除算法,是将垃圾回收分为 2 个阶段,分别是标记和清除

  • 标记:从根节点开始标记引用的对象

  • 清除:未被标记引用的对象就是垃圾对象,可以被清理

原理


这张图代表的是程序运行期间所有对象的状态,他们的标志位全部是 0(也就是未标记,以下默认 0 就是未标记,1 为已标记),假设这会儿有效内存空间耗尽了,JVM 将会停止应用程序的运行并开启 GC 线程,然后开始进行标记工作,按照根搜索算法,标记玩以后,对象的状态如下图。


可以看到,按照根搜索算法,所有从 root 对象可达的对象就被标记为了存活的对象,此时已经完成了第一阶段标记。接下来,就要执行第二阶段清除了,那么清除完之后,剩下的对象以及对象的状态如下图所示。

可以看到,没有被标记的对象将会回收清除掉,而被标记的对象将会留下,并且会将标记位重新归 0 .接下来就不用说了,唤醒停止的程序线程,让程序继续运行即可(结束 STW)。

优点


  • 可以看到标记清除算法解决了引用计数法中的循环引用的问题,没有从 root 节点引用的对象会被回收。


缺点


  • 效率较低,标记和清除两个动作都需要遍历所有的对象,并且在 GC 时,需要停止应用程序,对于交互性要求比较高的应用而言这个体验是非常差的


  • 通过标记清除算法清理出来的内存,碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所有清理出来的内存是不连贯的


用户头像

我们始于迷惘,终于更高水平的迷惘。 2020.03.25 加入

🏆 【酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“】 🏅 【Java技术领域,MySQL技术领域,APM全链路追踪技术及微服务、分布式方向的技术体系等】 🤝未来我们希望可以共同进步🤝

评论

发布
暂无评论
JVM-技术专题-虚拟机知识遗漏盘点