JVM学习(1) Java内存区域与OOM、SOF

参考书:周志明《深入理解Java虚拟机》

1. JVM运行时数据区

  JVM会在执行Java程序的时候将它所管理的内存划分为若干个不同的数据区。有些区域的生命周期与线程相同,即随依赖于用户线程的启动和结束而建立和销毁。
  JVM运行时数据区整体划分如图:
运行时数据区
注意,运行时数据区不包括执行引擎,本地库接口以及方法库。


2. 程序计数器(Program Counter Register)

  根据首先的图我们能够发现,程序计数器是属于线程私有的。

  程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号提示器。通过改变这个计数器的值,就能选取下一条需要执行的字节码指令。

  这个计数器中记录的是正在执行的虚拟机字节码指令地址。(注意:如果是Native方法则会为空)

重点: 这个内存区域是唯一不存在OOM(OutOfMemoryError)错误的区域


3. 虚拟机栈(VM Stack)

  与程序计数器一样,这个区域也是属于线程私有

  JVM栈是描述的Java方法执行的内存模型。每一个方法被执行的时候都会创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈,动态链接,方法出口等信息。

 每个栈帧的核心部分就是这个局部变量表。局部变量表主要包括是三个部分:

  1. 编译时可知的基本数据类型(boolean、int、float等)
  2. 对象引用(reference类型,注意,这个对象引用不是对象本身,实质上是一个指针,根据对象访问定位的实现方式不同,指向不同的内容)
  3. returnAddress(下一条字节码指令地址)

注意点:

  • 在方法运行期间,不会更改局部变量表的大小。

  • 如果线程请求深度 > JVM所允许的栈的深度,则会抛出StackOverFlowError错误

    • 对于HotSpot而言,其实不存在VM Stack 和Native Method Stack的区分,因此栈容量只能通过JVM参数 -Xss来决定
  • 如果JVM栈容量运行动态扩展(HotSpot不允许),那么扩展时无法申请到足够内存则会抛出OutOfMemoryError

  • 对于栈容量不可扩展的HotSpot而言,如果线程申请栈失败,则会抛出OutOfMemoryError。但是这里抛出的OOM和栈内存是否足够并不存在任何直接的关系,主要取决于操作系统本身的内存使用状态

  对于一个操作系统而言,操作系统分配给每个进行的内存是有限制的,譬如32位的Win系统的单个进程的内存限制为2GB。HotSpot提供的参数(-Xms -Xmx)能够限制堆的内存大小,程序计数器的所占内存大小可以忽略不计,方法区(注意只有JDK7以前的永久代可以通过JVM参数做限制)的内存大小可以通过参数(-XX:PermSize 和 -XX:MaxPermSize)来做限制。那么2GB减去方法区的内存大小,减去堆内存大小,减去直接内存和JVM本身消费的内存之后,剩下的就是栈所能用的内存了。
 如果每个线程分配到的栈内存越大,那么可以建立的线程数量自然就越少,就会出现线程申请栈空间失败的情况导致OOM

代码实例如下:(友情提示:记得保存东西,自己电脑上跑容易死机,吃过亏 /(ㄒoㄒ)/~~ )

/**
 * JVM参数:-Xss 108k
 */
public class TestStack {
    public static void main(String[] args) {
        while(true){
            new Thread(() -> {
                while(true){
                    
                }
            }).start();
        }
    }
}

  在这要注意,-Xss最小设定就是108K

  在某些情况下,我们可能出现线程较多的情况,而且还无法减少线程数量,那么就可能出现上面这种由于建立过多线程而导致OOM的情况。
  解决方法:
   1. 减少最大堆内存大小。那么对于整个JVM栈而言就能有更大的内存
   2. 减少栈容量。栈是线程私有的,容量越小,那么就可以创建越多的线程



  除非在线程申请栈空间时失败,才会出现OOM,否则无论是栈帧太大,还是栈容量太小,都只会出现StackOveflowError
  比如调小的栈内存容量后,递归调用方法,不断插入栈帧,导致超出所允许的栈深度,出现的是StackOverflowError。
  代码示例如下

/**
 * JVM参数: -Xss 108k
 */
public class TestStack {
    public static void fun(){
        fun();
    }

    public static void main(String[] args) {
        fun();
    }
}

  再比如对某一个方法定义一大堆本地变量,导致栈帧中的局部变量表增长,也只会出现StackOverflowError。



4. 本地方法栈(Native Method Stack)

  本地方法栈和虚拟机栈作用是基本上一样的,区别在于虚拟机栈中是Java方法,而本地方法栈中是本地(Native)方法而已。
  由于《Java虚拟机规范》对于本地方法栈几乎没有作任何限制,因此虚拟机能够自由的实现它。HotSpot甚至不区分本地方法栈和虚拟机栈。
  与虚拟机栈一样,栈深度溢出抛出StackOverflowError,动态扩展失败抛出OutOfMemoryError。



5. Java 堆(Java Heap)

  java堆的内存通常来说是最大的一块,是属于所有线程 共享 的部分。
  这块内存的唯一作用:存放对象实例
  正式因为如此,所以Java堆是垃圾收集器管理的内存区域。由于垃圾收集器大部分是基于分代收集理论设计的,所以我们认为的Java堆就是分为新生代,老生代,永久代,Eden区,From Survivor区,To Survivor区。但是实际上这些区域的划分只是一部分垃圾收集器的设计风格,并非是Java虚拟机实现的固定内存布局。将Java堆细分只是为了更好的回收内存,或者说是为了更快的分配内存。
  但是到了现在JDK9以后的G1收集器,CMS,ZGC等等,都不再采用分代收集设计模式。

但是对于现在主流的JDK8而言(下一个LTS版是JDK11,值得学习),还是采用的这种分代收集设计模式。

5. 1 内存分配

  从内存分配的角度来看,由于堆是所有线程共享的,那么就会存在一个分配内存的问题,而分配内存又存在两个方面的问题:

  1. 如何划分可用空间

   首先来说一下如何来划分可用空间。在使用了ClassLoader类加载器之后,对象所需要的内存大小在此时就已经能够确定了,而不是在new的时候才确定对象所需的内存大小。我们为对象分配内存空间,实际上就是说将一块确定大小的内存块从堆中划分出来,交给这个对象。因此存在两种分配方法:

  1) 指针碰撞(Dump The Point)如果我们的Java堆是绝对规整的,即所有使用的内存在一边,所有未使用的内存在另外一边,中间依靠一个指针来标识。那么我们分配内存,只需要将指针往空间内存那边移动一段与对象内存大小相等的距离即可。

  2)空闲链表(Free List)由于我们GC采用的算法不同,有些GC算法不存在压缩整理(Compact)这一过程,所以我们堆中内存可能是不规整的,已经被使用和空闲内存混合在一起,那么指针碰撞就不再适用。因此JVM就需要去维护一个空闲列表,记录哪些内存块是可以使用的。分配是从列表上找到一块足够大的空间分配给对象,再更新列表。

  2. 多线程并发问题

  内存空间的分配说完了,然后就是一个分配的时间问题。多线程程序中对象的创建是非常频繁的,那么我们就可能出现线程不安全的情况,正在给A分配内存,还没有更新指针,B就进来分配内存,就会出现错误。
  因此有两种解决办法:

  1) 同步处理,对于分配内存这一动作采取同步处理,JVM实际上就是使用CAS + 失败重试的方法保证的。

  2)本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)即每一个线程在Java堆上预先分配一小块内存,哪个线程中new对象了,就在TLAB上面分配,只有TLAB使用完了,才采用同步锁定,重新划分一块内存给TLAB。

  能够使用参数-XX:+/-UseTLAB来设定

  根据《Java虚拟机规范》规定,Java堆可以处于物理上不连续的内存空间中。但是在逻辑上我们应该将它视为连续的内存空间。

5.2 对象存储形式

  内存分配完了,那么就是下一个问题,我们对象在分配好的内存上是如何存储的呢?存储的形式是什么?

  在HotSpot中,对象再堆内存中的存储可以划分为三个部分:对象头,实例数据,对齐填充。

  对象头中主要包含两类信息:对象自身运行时所需的数据,比如说Hash码,GC分代年龄,线程持有的锁等,这部分数据称为“Mark Word“;另外一类信息就是类型指针,指向它的类型元数据,JVM通过这个指正来确定这个对象属于哪个类;如果这个对象是个数组,那么在对象头中还应该有一部分用来记录数组长度。

  实例数据,这部分就是对象存储的有效信息,即代码中定义的各种字段(包括从父类继承而来的)内容。

字段内容的存储收到JVM分配策略参数-XX:FieldsAllocationStyle和定义顺序的影响,通常情况下,相同宽度的字段总是被分配到一起存放,在满足这个条件的前提下,父类变量在子类之前。如果-XX:CompactFields参数为true(默认为true),那么子类中较窄的变量允许插入到父类变量的空隙之中。

  对齐填充,没有任何意义,只是为了对齐,因为HotSpot要求任何对象的大小都必须是8字节的整数倍,所以这个只是为了对齐而已。

5.3 对象访问

  内存分配完了,对象存好了,我们的最终目的就是要使用对象。

  reference类型在《Java虚拟机规范》中只规定了它是一个指向对象的引用,并没有规定这个引用如何去定位和访问堆内存中的对象,所以对象的访问方式也是根据JVM的实现而定的。主流访问方式分为两种:

  1. 使用句柄访问

  Java堆中需要划分出来一块,来做为句柄池,reference中存储的对象句柄的地址,而句柄中包含了对象实例数据和类型数据的地址信息,其大致结构如图。
在这里插入图片描述
  这么做的好处是什么?使用句柄访问的最大好处就是在对象被移动的时候(比如GC回收的时候存在Compact阶段)只需要更改句柄中的实例数据指针,不需要去更改reference。

  2. 直接指针访问

  直接指针访问则简单了,reference中存储的直接就是对象地址。这样也就能减少一次间接访问的开销。
在这里插入图片描述

HotSpot采用的是直接指针访问

5. 4 堆内存OOM

  最后面还是内存溢出的问题。由于Java堆是属于可扩展的(通过参数-Xms和-Xmx来设定,在实际项目中如果要进行JVM调优,这两个应该设定为相等)那么当堆无法完成内存分配,而且扩展也达到上限,就会抛出OOM。

java堆溢出代码示例:

/**
 * JVM参数: -Xms 10m -Xmx 10m
 */
public class TestHeap {

    public static void main(String[] args) {
        List<TestHeap> list = new ArrayList<>();
        while(true){
            list.add(new TestHeap());
        }
    }
}

错误信息:
   在这里插入图片描述


6. 方法区(Method Area)

  方法区与Java堆一样,属于各个线程 共享 的内存区域。

  方法区作用是,存储被JVM加载的类型信息、常量、静态变量等数据。

  《Java虚拟机规范》里面并没有约束方法区的实现,所以方法区存在不同的实现方式,比如HotSpot在JDK7以前实现方式是永久代;而JDK8以后变成了元空间。

  JDK7之前HotSpot为了方便方法区这部分的内存管理,将垃圾回收器的分代设计模式扩展到了方法区,使得能够像Java堆一样管理这部分内存,才有了永久代这一概念。也就是说,我们在堆内存在逻辑上分为新生代,老生代,永久代,但是物理上,堆内存是不包含永久代的。


  JDK6开始,HotSpot就开始逐渐废弃永久代,JDK7以后,将原本放在永久代的字符串常量池,静态变量等移至堆中,JDK8之后完全废弃永久代,改为基于本机物理内存的元空间。

  那么也就是说,现在主流版本的JDK8,我们常用的一些东西,比如说字符串常量池,静态变量在堆中;类的元数据(类的结构信息)在原空间中(本机物理内存)。



7. 运行时常量池

  运行时常量池是Java方法区的一部分,A.class文件中,除了包含类的字段,方法等信息之外,还包含一项信息就是常量池表(Constant Pool Table,也有的叫class文件常量池)用于存放编译期间生成的各种字面量(也就是我们说的常量,比如final关键字声明的常量,文本字符串等)和符号引用(类和结构的完全限定名,字段名,方法名等)。这部分内容在类加载以后,将存放到方法区的运行时常量池中。

在这里插入图片描述

  但是并非只有编译期间产生的东西才能放入这个运行时常量池中,我们常用的就是java.lang.String类的intern()入池方法。

  注意运行时常量池和字符串常量池不是一个东西
先来看一下Class文件的常量池表(非运行时常量池,这个常量池在类加载后会被放入运行时常量池):
代码:

public class TestConstantPool {
    public static void main(String[] args) {
        String s = "hello";
    }
}

  在TestConstantPool.class文件目录下cmd命令:javap -v TestConstantPool.class

在这里插入图片描述
  就能够看见Constant pool底下有我们定义的字符串常量“hello”
  其中在最开始部分我们能够看见一行,#2 = String #22 ,这个意思是表示这个位置是字符串常量,指向#22

7.1 运行时常量池和字符串常量池的区别

  字符串常量池实际只是一个哈希表,其中存储的不是我们的字符串实例对象,而是一个个首次出现的实例的引用,类的实例对象还是存储在堆中。字符串常量池是为所有类共享的。 (JDK7以后,字符串常量池也移动到了堆中)

  而运行时常量池,由于需要存储Class类信息的常量池表,所以实际上运行时常量池会被分割成每一个类占一部分,那么类中String字符串,存储还是一个引用,这个引用和字符串常量池中的引用保持一致。

  这样也就能够保证每一个类中的相同字符串常量,都是同一个。

代码示例:

public class TestConstantPool {
    public static void main(String[] args) {
        String s1 = "hello";
        // new String 是不入字符串常量池的,必须要手工入池
        String s2 = new String("hello");

        System.out.println(s1 == s2); //false

        //intern()返回的是字符串常量池的引用
        String s3 = new String("hello").intern();
        System.out.println(s3 == s1); //true
    }
}

还有要注意,入字符串常量池的一定是首次出现的实例的引用

        String str1 = new StringBuilder("he").append("llo").toString();
        System.out.println(str1 == str1.intern());  //true

        String str2 = new StringBuilder("jav").append("a").toString();
        System.out.println(str2.intern() == str2);  //false

        String str3 = new StringBuilder("world").toString();
        System.out.println(str3.intern() == str3);  //false

  为什么第二个是false呢?因为java这个字符串在加载sun.misc.Version的时候就入池了,所以首次出现的实例引用并非str2。

  那么为什么第三个是false呢?就因为我短(少个append)?我们还是使用这个命令:javap -v TestConstantPool.class看一下常量池(Class文件常量池表,非运行时常量池):
在这里插入图片描述
  我们会发现,在Class文件常量池中根本不存在hello这个字符串,而是我们用来拼接的“he” 和 “llo”,然后我们再看一下toString()方法,StringBulider的toString()方法返回的是一个new String,所以我们输出“hello”实际上是一个拼接得来的。

    @Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }

  所以我们就能够发现,不存在字符串“hello”,也就不存在入池这一操作,当我们调用str.intern()的时候,就是首次入池,所以第一个sout返回的就是true。

  那么第三个呢?实际上我们在new StringBuilder(“world”)的时候,字符串“world”就已经入池了,所以首次入池的引用根本就不是str3,那么自然sout的就是false

7.2 方法区OOM

  方法区的OOM很好观察,我们只需要不断创建字符串常量,将运行常量池撑爆即可(JDK6之前,因为JDK7就已经将字符串常量池移到了堆中,炸的就是堆了,如代码)

    public static void main(String[] args) {
        String str = "hello";
        while(true){
            //保证每次都不重复,相当于不断的入池一个字符串
            str += str + new Random().nextInt(11111111) + new Random().nextInt(999999999);
        }
    }

在这里插入图片描述



8. 直接内存

  直接内存并不是JVM运行时数据区的一部分,但是这个部分被频繁的使用也可能导致OOM。

  在JDK1.4 是引入的NIO,就是使用Native函数库方法来直接分配堆外内存,然后通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。

  显然,直接内存并不会收到Java堆大小的限制,但是既然是内存,那么肯定还是会收到本机物理内存的限制,所以在配置-Xmx的时候,如果忽略掉了直接内存,这样动态扩展堆的时候就很容易使得各个内存区域总和 > 本机物理内存限制,从而导致OOM的错误。

  能够通过-XX:MaxDirectMemorySize来指定大小,如果不指定的话,默认和-Xmx一致。

  注意:由直接内存导致的OOM,一个最明显的特征就是Dump出来的文件没有明显的异常情况,而且文件较小

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值