赞
踩
堆(Heap):通过 new 关键字创建出来的对象都会使用堆内存。
特点:
1、线程共享,堆中对象都需要考虑线程安全问题
2、有垃圾回收机制
废话不多说,代码来一波。先来一个堆内存溢出的例子:
import java.util.ArrayList; import java.util.List; public class HeapError { public static void main(String[] args) { int i = 0; try { List<String> list = new ArrayList<String>(); String a = "Hello,world! "; while (true){ list.add(a); a += a; i++; } }catch (Exception e){ e.printStackTrace(); System.out.println(i); } } }
运行结果如下:(OutOfMemoryError表示内存溢出,具体的Java heap space说明 溢出的内存块是我们的堆空间。
为什么会导致内存溢出呢?说好的垃圾回收机制呢?其实很简单,首先,他确实有垃圾回收机制,但是堆的内存空间并不是无限大的,而且垃圾回收机制,他回收的是不再被需要的对象的内存空间,上面的代码,无论是List还是a,他们在死循环中,一直不断地被需要,所以,内存不会释放,随着list不断add(a)和a的不断+a,内存自然就爆了。
堆内存,默认情况下,windows跟我们的虚拟机栈一样,是根据我们自身的内存分配的,大概是内存的4/1(因为我是12G,他分配了大概3G)。为了防止部分代码短时间运行不会导致堆内存溢出,但长时间运行积累后导致的堆内存溢出的问题,我们有时候也可以使用虚拟机命令 -Xmx10m 来设置堆内存的大小为10M,让此类问题尽早暴露出来。
这里介绍三个工具:
1、jps工具:查看当前系统中有哪些 java 进程。
2、jmap工具:查看某个时刻堆内存使用情况。(使用命令 jmap -heap 进程id)
3、jconsole工具:图形界面的,多功能的检测工具,可以连续监测。
来一波代码演示:
public class HeapDiagnose1 { public static void main(String[] args) throws InterruptedException { System.out.println("Preparing the first snapshot"); //给20s时间打命令 Thread.sleep(20000); //分配5M的内存空间(使用堆空间) byte[] array = new byte[1024 * 1024 * 5]; System.out.println("Preparing the second snapshot"); //给10s时间打命令 Thread.sleep(10000); //刚才new的不用了,可以被垃圾回收 array = null; //垃圾回收 System.gc(); System.out.println("Preparing the third snapshot"); //10s后再运行结束,给时间截图 Thread.sleep(10000L); } }
运行后,我们先使用jps工具查看目前的java进程如下:(可以看到,我们的HeapDiagnose1的进程号为12436)
然后我们分别在三个输出语句输出后 快速 用jmap工具/命令,查看此时的堆内存使用情况。这里,由于新new出来的对象,一开始实在Eden区域,所以我们直接看Heap Usage下的Eden区域,然后看一下刚才的三个快照堆内存的使用情况的对比。
一开始我们使用的大概是4.8033M内存。
然后,我们为array new了一个5M的对象。所以,我们的堆内存使用也大概加了5M,变成了9.8033M。
最后,我们让array = null,并进行了垃圾回收,此时堆内存释放后情况如下(占用的堆内存中Eden区域的内存只有0.9600M):
顺便说下,Heap Configuration下的信息是我们的堆内存的配置信息。
当然,我们也可以使用jconsole来动态检测我们堆内存的情况。在命令框(Terminal)中输入命令jconsole可以打开jconsole面板。
选择我们当前运行的Java进程,然后点击不安全链接即可。(从折线图不难看出,堆内存的突增和骤降过程),这里还可以看我们类的加载情况,内存数以及CPU占用率和检测我们的程序是否发生死锁。
有些时候,我们可能会出现这样一种情况就是:我们进行垃圾回收以后,我们的堆内存占用还是很高,说明我们的堆内存中某个区域可能存在着某些个对象,他一直被使用着。那么我们可以使用工具 jvisualvm 打开我们的可视化虚拟机工具。
连接上我们的Java进程后,点击监视,可以执行垃圾回收,回收后,如果我们的堆内存占用还是很多,那么就使用Dump,抓取当前堆内存的快照,以此来分析,到底是哪个对象一直占据着内存不放,并以此来优化我们的代码。
快照生成后,点击下图查找按钮,即可查找当前堆空间中占用内存最大的20个对象。
这里,可以看到占用最多的是一个ArrayList,他占用了大概209M的内存。我们还可以通过点击他里面的elementData来查看到底存了些什么东西等等。
方法区:方法区是Java虚拟机中所有线程共享的区域,他存储了跟类结构相关的一些信息和一个运行时常量池,例如类的成员变量和方法数据以及成员方法和构造器方法的代码。
方法区在虚拟机启动的时候就被创建;方法区如果申请内存时发现内存不足,也会抛出内存溢出的OutOfMemoryError。
在JVM1.6的时候,方法区是通过永久代(PermGen)来实现的。我们的StringTable(字符串表)也放在我们的方法区的常量池中。
而JVM1.8的时候,StringTable已经被移除到我们的Heap(堆)中,方法区也被转移到我们的本地内存下,通过元空间来实现。
老规矩,直接给会导致内存溢出的例子:
由于JVM1.8的方法区在我们的本地内存中,所以,很难让他发生方法区的溢出现象,所以,我们还要给一个虚拟机命令,设置元空间的大小(设置为8MB):-XX:MaxMetaspaceSize=8m, 添加位置如下:
设置完后运行,结果如下:(出现了OutOfMemoryError报错信息,并告诉我们是Metaspace元空间发生的内存溢出)
如果是JVM1.6的话,我们设置的永久代的方法区大小命令为:-XX:MaxPermSize=8m, 版本号要改为Opcodes.V1_6。报错信息如下:
内存溢出的场景:我们当然不会像上面的代码那样写我们的源码,但是,我们要知道,在Spring和框架中,就有动态生成代理类,而这些代理类正是AOP的核心;MyBatis框架也有用到cglib,利用cglib来产生map接口的实现类。
当然了,由于方法区从原先的栈内存中向我们的本地内存转移的缘故以及垃圾回收的优化,JVM1.8后要产生方法区溢出还是比较困难的。但这并不能成为我们掉以轻心的原因,要知道,我们还是有很多框架都使用了动态代理等技术的,哪天如果出现了这种内存溢出的情况,我们可以考虑一下是不是我们对框架使用不合理导致的方法区的内存溢出。
我们都知道,我们的java代码,经过 javac 编译后会变成字节码文件(.class)。那么这个字节码文件,他都包含些什么呢?
字节码文件包含:类的基本信息、常量池、类方法定义(包括虚拟机指令)。
来看个熟悉的代码:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
然后我们编译一下我们的HelloWorld.java文件并反编译我们的字节码.class文件。
javac HelloWorld.java
javap -v HelloWorld.class
反编译后,首先输出的是类的基本信息,包括最后一次修改的事件,签名,类名和jdk版本号等等。
然后是我们的常量池。
再往下看,我们可以看到类方法的定义。比如HelloWorld(),是自动生成的无参构造。
还有我们得到main方法以及他反编译后的虚拟机指令。getstatic表示获取静态变量(我们的System.out),然后使用ldc加载一个参数(我们字符串Hello,world!),然后使用invokevirtual调用我们的静态变量System.out的方法println,之后我们的Hello,world就会被输出并打印在控制台,最后return,方法结束。
这里可以详细解释一下,getstatic后面接了#2,其实就是使用了常量池里面的#2,我们可以看看。
从上面我们可以看到,#2是成员变量,他关联了#16和#17。所以,我们再往下翻。
这里,他又告诉我们,#23对应的类是java/lang/System,而#24和#25告诉我们,对应的变量名是out,out的类型是Ljava/io/PrintStream。
其他的其实也类似就是,这里就不详细说明了。
最后来总结一下:
常量池,其实就是上面我们看到的那个表,虚拟机指令根据这张常量表找到要执行的类名、方法名和参数类型、字面量等信息。
运行时常量池,指的是当我们的常量池中的类被记载后,常量池信息就会被放入内存的运行时常量池中,此时我们的那些个符号地址(#1、#2、#3 。。。)也会被相应的更换为真实的内存地址。
首先,说一下我们的常量池和串池的关系吧。我们知道常量池的信息,在运行的时候都会被加载到运行时常量池中,需要注意的是,此时我们的串池还是空的,他得等到字符串的初始化命令被执行,即虚拟机的ldc #n被执行的时候,#n所对应的字符串信息 才会被放入到串池中,变为java的字符串对象。举个例子:
现有源码如下:
String s1 = "a";
String s2 = "b";
String s3 = "ab";
反编译后的虚拟机指令如下:
当这三个字符串还在常量池中或者刚被加载进运行时常量池的时候,我们要知道,他还没有立刻被转换为java的字符串对象并存放到串池中去。直到运行他们的ldc指令的时候,他们才会逐个从符号“a”、“b"、”ab“ 被转换为Java字符串对象并放到我们的串池中。这个串池使用的是HashTable的结构,且不能扩容。由于使用的是HashTable结构,所以我们在创建其他的String 变量名 = “a”、“b”、"ab"的时候,他们使用的是同一个键值对。也是同一个地址空间。 当然了,如果使用的是String 变量名 = new String(“a”);那么,他是在堆中新开辟一个String对象的地址空间,使用的当然也是一个新的地址。
这里,我们再添加一段代码
String s4 = s1 + s2;
然后看一下反编译后的命令:(红色框出来的是s4 = s1 + s2 反编译后的指令)
我们可以看到,首先,他new了一个StringBuilder对象,然后调用了StringBuilder对象的无参构造(“init”: ()),之后加载了1和2的内容(其实就是字符串a和b)并使用StringBuilder对象的append方法,最后调用toString()方法。所以,简单说,我们的String s4 = s1 + s2; 就等价于 new StringBuilder().append(s1).append(s2).toString(); 我们可以再看看他的toString方法。
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
最终结果是new了一个String对象。因此,我们的s4他最后动用的其实不是放在串池中,而是放到了我们的堆内存中。所以,我们的s3 == s4 返回的应该是false。可以跑一下源码:
public class StringTable {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);
}
}
那如果现在我们再加一个s5 = “a” + “b”;呢?
public class StringTable {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = "a" + "b";
}
}
他的反编译结果如下:(可以看到跟上面String s3 = “ab”;)是一样的,因为“a”和“b”是常量,直接相加的结果是一个固定值,在编译的时候,会被做优化处理,且最终存放的位置也是串池,由于串池的结构是HashTable,所以,他的地址跟s3应该是一样的。
对于创建在堆中的字符串对象,我们可以通过对象方法intern()将该字符串对象的引用放入到串池中(如果串池中没有该字符串的话),该方法会返回最终在串池中该字符串字段的引用。举个例子:
public class StringTable {
public static void main(String[] args) {
String str = new String("a") + new String("b");
// str.intern();
String s = "ab";
System.out.println(str == s);
}
}
调试一下这段代码:可以看到,他们虽然都是“ab”,但是由于一个在堆中,一个在串池中,所以,他们所对应的地址是不一样的。
取消注释str.intern(); ,再来看下:(由于,我们将堆中的“ab”的引用放到了串池,而s从串池直接取到了“ab”,所以,我们此时的str和s的地址是一样的。
但是!如果我们在堆中引用放入串池之前,串池就已经有“ab”了,那么,根据HashTable的特性,他不会接收堆传过来的字符串的引用。如下:
public class StringTable {
public static void main(String[] args) {
String s = "ab";
String str = new String("a") + new String("b");
str.intern();
String s2 = "ab";
System.out.println(str == s);
System.out.println(s == s2);
}
}
总结一下知识点:
1、常量池中的字符串仅仅只是符号,直到虚拟机执行ldc命令后,才转变为对象。
2、利用串池的机制(HashTable),可以避免重复创建字符串对象。
3、字符串变量拼接的原理是StringBuilder.append().append().toString()。(创建的对象存放在堆内存中,即开辟一个新的地址空间,)
4、字符串常量拼接的原理是编译期间进行优化。(串池中有直接取)
5、利用字符串对象的intern()方法可以向串池中放入一个他还没有的字符串的引用(浅拷贝)。(如果是jdk1.6的话,那么intern做的是深拷贝,即整了一个新的字符串放入其中)
前面我们说了,自JDK1.7开始,StringTable便被设计到堆内存中,而我们知道,堆内存是有垃圾回收的。 下面,我们就来说一下 StringTable的垃圾回收现象:
演示之前,我们必须要加几条虚拟机命令:
-Xmx10m:设置堆内存为10MB
-XX:+PrintStringTableStatistics:输出StringTable的统计信息
-XX:+PrintGCDetails -verbose:gc:输出垃圾回收的细节(包括次数和时间等等)
使用IDEA工具在VM options中输入这些虚拟机命令:
然后我们通过代码来演示一下:
public class StringTableDemo1 {
public static void main(String[] args) throws InterruptedException{
int i = 0;
try{
}catch(Throwable e){
e.printStackTrace();
}finally{
System.out.println(i);
}
}
}
运行结果如下:
Number of buckets表示桶的个数
Number of entries 表示条目的个数
Number of literals 表示串池中的字符串对象的个数
后边的数字则是对应的个数,bytes是他们所占用的字节数。这里,一开始就有1754个条目和字符串对象是因为他们的我们的类名和方法名等数据是以字符串常量的形式存在的,所以一开始就有1754个数据。
然后,我们再看这一段代码:
public class Demo{
public static void main(String[] args) throws InterruptedException{
int i = 0;
try{
for( ;i < 100; i++){
String.valueOf(i).intern();
}
}catch(Throwable e){
e.printStackTrace();
}finally{
System.out.println(i);
}
}
}
运行结果如下:
可以看到,串池中刚好增加了100个条目和字符串对象。
现在,我们再改一下,我们让他增加10000个对象。
public class StringTableDemo1 {
public static void main(String[] args) throws InterruptedException{
int i = 0;
try{
for( ;i < 10000; i++){
String.valueOf(i).intern();
}
}catch(Throwable e){
e.printStackTrace();
}finally{
System.out.println(i);
}
}
}
运行结果:
我们发现,从最开始的1774并没有变成11774,而是变成了10475,这里其实就发生了垃圾回收现象,我们把运行结果往上拉可以看到这样的输出:
这是我们的垃圾回收的细节:
Allocation Failure表示由于内存空间分配失败触发了垃圾回收。
后面的Times是垃圾回收所占用的时间,可以看到其实还是很短的。实际花费了0.02秒的时间。(弱弱说一句,我的电脑是相当垃圾的,好一点的电脑应该都是0.00 sec)
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。