浅谈Java的虚拟机结构以及虚拟机内存的优化
作者:goldensun 时间:2021-12-01 21:59:49
工作以来,代码越写越多,程序也越来越臃肿,效率越来越低,对于我这样一个追求完美的程序员来说,这是绝对不被允许的,于是除了不断优化程序结构外,内存优化和性能调优就成了我惯用的“伎俩”。
要对Java程序进行内存优化和性能调优,不了解虚拟机的内部原理(或者叫规范更严谨一点)是肯定不行的,这里推荐一本好书《深入Java虚拟机(第二版)》(Bill Venners著,曹晓刚 蒋靖 译,实际上本文正是作者阅读本书之后,对Java虚拟机的个人理解阐述)。当然了,了解Java虚拟机的好处并不仅限于上述两点好处。从更深一点的技术层面上看,了解Java虚拟机的规范和实现,将更加有助于我们编写高效、稳定的Java代码。比如,假如了解Java虚拟机的内存模型,了解虚拟机的内存回收机制,那么我们就不会过分依赖它,而会在需要的时候显式的”释放内存”(Java代码不能显式释放内存,但是可以通过释放对象引用告知垃圾回收器回收该对象需要被回收),以降低不必要的内存消耗;假如我们了解Java栈的工作原理,那么我们就可以通过减少递归层数,减少循环次数来降低堆栈溢出的风险。可能对于应用开发人员来说,可能不会直接去涉及这些Java虚拟机底层实现的工作,但是了解这些背景知识,或多或少,都会对我们写的程序产生潜移默化的好的影响。
本篇文章,将简明扼要的说明Java虚拟机的体系结构和内存模型,如有用词不妥或解释不准确之处,请不吝指正,深感荣幸!
Java 虚拟机体系结构
类装载子系统
Java虚拟机有两种类装载器,分别是启动类装载器和用户自定义装载器。
通类装载子系统通过类的全限定名(包名和类名,网络装载还包括 URL)将 Class 装载进运行时数据区。对于每一个被装载的类型,Java虚拟机都会创建一个java.lang.Class类的实例来代表该类型,该实例被放在内存中的堆区,而装载的类型信息则位于方法区,这一点和所有其他对象都是一样的。
类装载子系统在装载一个类型前,除了要定位和导入对应的二进制class文件外,还要验证导入类的正确性,为类变量分配并初始化内存,以及解析符号引用为直接引用,这些动作严格按照以下顺序进行:
1)装载——查找并装载类型的二进制数据;
2)连接——执行验证,准备以及解析(可选)
3)验证 确保被导入类型的正确性
4)准备 为类变量分配内存,并将其初始化为默认值
5)解析 把类型中的符号引用转换为直接应用
方法区
对于每一个被类装载子系统装载的类型,虚拟机都会保存下列数据到方法区:
1.类型的全限定名
2.类型超类的全限定名(java.lang.Object没有超类)
3.类型是类类型还是接口类型
4.类型的访问修饰符
5.任何直接超接口的全限定名有序列表
除了上述基本类型信息,还将保存如下信息:
6.类型的常量池
7.字段信息(包括字段名、字段类型、字段修饰符)
8.方法信息(包括方法名、返回类型、参数的数量和类型、方法修饰符,如果方法不是抽象和本地的,还将保存方法的字节码、操作数栈和该方法栈帧中的局部变量区的大小和异常表)
9.常量以外的所有类变量(其实就是类的静态变量,因为静态变量是所有实例共享的,且与类型直接相关,所以他们是类一级的变量,作为类的成员被保存在方法区)
10.一个到类ClassLoader的引用
//返回的就是刚才保存的ClassLoader引用
String.class.getClassLoader();
一个到Class类的引用
//将返回刚才保存的Class类的引用
String.class;
注意,方法区也是可以被垃圾回收器回收的。
堆
Java程序在运行时创建的所有类实例或数组都放在同一个堆中,而每一个Java虚拟机也是有一个堆空间,所有线程共享一个堆(这就是一个多线程的Java程序会产生对象访问的同步问题的原因了)。
由于每一种Java虚拟机都有对虚拟机规范的不同实现,所以我们可能不知道每一种Java虚拟机在堆中是以何种形式表示对象实例的,不过我们可以通过下面这可能的实现来一窥端倪:
程序计数器
对于运行中的Java程序而言,每一个线程都有自己的PC(程序计数器)寄存器,它是在该线程启动时创建的,大小为一个字长,用来保存需要被执行的下一行代码的位置。
Java栈
每一个线程都有一个Java栈,以栈帧为单位保存线程的运行状态。虚拟机对Java栈的操作有两种:压栈和出栈,二者都已帧为单位。栈帧保存了传入参数、局部变量、中间运算结果等数据,在方法完成时被弹出,然后释放。
看一下两个局部变量相加时栈帧的内存快照
本地方法栈
这是 Java 调用操作系统本地库的地方,用来实现 JNI(Java Native Interface,Java 本地接口)
执行引擎
Java虚拟机的核心,控制装入 Java 字节码并解析;对于运行中的Java程序而言,每一个线程都是一个独立的虚拟机执行引擎的实例,从线程生命周期的开始到结束,他要么在执行字节码,要么在执行本地方法。
本地接口
连接了本地方法栈和操作系统库。
注:文中所有提到”Java虚拟机”的地方都是指”JavaEE和JavaSE平台的Java虚拟机规范”。
虚拟机内存优化实践
既然提到内存,就不得不说到内存泄露。众所周知,Java是从C++的基础上发展而来的,而C++程序的很大的一个问题就是内存泄露难以解决,尽管Java的JVM有一套自己的垃圾回收机制来回收内存,在许多情况下并不需要java程序开发人员操太多的心,但也是存在泄露问题的,只是比C++小一点。比如说,程序中存在被引用但无用的对象:程序引用了该对象,但后续不会或者不能再使用它,那么它占用的内存空间就浪费了。
我们先来看看GC是如何工作的:监控每一个对象的运行状态,包括对象的申请、引用、被引用、赋值等,当该对象不再被引用时,释放对象(GC本文的重点,不做过多阐述)。很多Java程序员过分依赖GC,但问题的关键是无论JVM的垃圾回收机制做得多好,内存总归是有限的资源,因此就算GC会为我们完成了大部分的垃圾回收,但适当地注意编码过程中的内存优化还是很必要的。这样可以有效的减少GC次数,同时提升内存利用率,最大限度地提高程序的效率。
总体而言,Java虚拟机的内存优化应从两方面着手:Java虚拟机和Java应用程序。前者指根据应用程序的设计通过虚拟机参数控制虚拟机逻辑内存分区的大小以使虚拟机的内存与程序对内存的需求相得益彰;后者指优化程序算法,降低GC负担,提高GC回收成功率。
通过参数优化虚拟机内存的参数如下所示:
Xms
初始Heap大小
Xmx
java heap最大值
Xmn
young generation的heap大小
Xss
每个线程的Stack大小
上面是三个比较常用的参数,还有一些:
XX:MinHeapFreeRatio=40
Minimum percentage of heap free after GC to avoid expansion.
XX:MaxHeapFreeRatio=70
Maximum percentage of heap free after GC to avoid shrinking.
XX:NewRatio=2
Ratio of new/old generation sizes. [Sparc -client:8; x86 -server:8; x86 -client:12.]-client:8 (1.3.1+), x86:12]
XX:NewSize=2.125m
Default size of new generation (in bytes) [5.0 and newer: 64 bit VMs are scaled 30% larger; x86:1m; x86, 5.0 and older: 640k]
XX:MaxNewSize=
Maximum size of new generation (in bytes). Since 1.4, MaxNewSize is computed as a function of NewRatio.
XX:SurvivorRatio=25
Ratio of eden/survivor space size [Solaris amd64: 6; Sparc in 1.3.1: 25; other Solaris platforms in 5.0 and earlier: 32]
XX:PermSize=
Initial size of permanent generation
XX:MaxPermSize=64m
Size of the Permanent Generation. [5.0 and newer: 64 bit VMs are scaled 30% larger; 1.4 amd64: 96m; 1.3.1 -client: 32m.]
下面所说通过优化程序算法来提高内存利用率,并降低内存风险,完全是经验之谈,仅供参考,如有不妥,请指正,谢谢!
1.尽早释放无用对象的引用(XX = null;)
看一段代码:
public List<PageData> parse(HtmlPage page) {
List<PageData> list = null;
try {
List valueList = page.getByXPath(config.getContentXpath());
if (valueList == null || valueList.isEmpty()) {
return list;
}
//需要时才创建对象,节省内存,提高效率
list = new ArrayList<PageData>();
PageData pageData = new PageData();
StringBuilder value = new StringBuilder();
for (int i = 0; i < valueList.size(); i++) {
HtmlElement content = (HtmlElement) valueList.get(i);
DomNodeList<HtmlElement> imgs = content.getElementsByTagName("img");
if (imgs != null && !imgs.isEmpty()) {
for (HtmlElement img : imgs) {
try {
HtmlImage image = (HtmlImage) img;
String path = image.getSrcAttribute();
String format = path.substring(path.lastIndexOf("."), path.length());
String localPath = "D:/images/" + MD5Helper.md5(path).replace("\\", ",").replace("/", ",") + format;
File localFile = new File(localPath);
if (!localFile.exists()) {
localFile.createNewFile();
image.saveAs(localFile);
}
image.setAttribute("src", "file:///" + localPath);
localFile = null;
image = null;
img = null;
} catch (Exception e) {
}
}
//这个对象以后不会在使用了,清除对其的引用,等同于提前告知GC,该对象可以回收了
imgs = null;
}
String text = content.asXml();
value.append(text).append("<br/>");
valueList=null;
content = null;
text = null;
}
pageData.setContent(value.toString());
pageData.setCharset(page.getPageEncoding());
list.add(pageData);
//这里 pageData=null; 是没用的,因为list仍然持有该对象的引用,GC不会回收它
value=null;
//这里可不能 list=null; 因为list是方法的返回值,否则你从该方法中得到的返回值永远为空,而且这种错误不易被发现、排除
} catch (Exception e) {
}
return list;
}
2.谨慎使用集合数据类型,如数组,树,图,链表等数据结构,这些数据结构对GC来说回收更复杂。
3.避免显式申请数组空间,不得不显式申请时,尽量准确估计其合理值。
4.尽量避免在类的默认构造器中创建、初始化大量的对象,防止在调用其自类的构造器时造成不必要的内存资源浪费
5.尽量避免强制系统做垃圾内存的回收,增长系统做垃圾回收的最终时间
6.尽量做远程方法调用类应用开发时使用瞬间值变量,除非远程调用端需要获取该瞬间值变量的值。
7.尽量在合适的场景下使用对象池技术以提高系统性能