《深入理解java虚拟机》——java内存区域与内存溢出异常

我是很喜欢用java语言编写代码的。从开始学习到现在其实也是在一步步体会java语言的各方面,开始看深入理解java虚拟机这本书觉得java虚拟机的内部感觉就像是一个操作系统,也可以说是个计算机。想要深入的理解我觉得需要先从整体去看。为什么需要java虚拟机,java虚拟机到底是个什么?我会先从这个方向开始理解。

JDK:java程序设计语言、java虚拟机、java API类库三部分。

JRE:支持java程序运行的标准环境。

java虚拟机实现了java语言与平台的无关性。意思就是java语言在不同的平台(操作系统等环境)运行都是可以的,因为java虚拟机将java源文件编译成class字节码文件,class字节码在java虚拟机中被解释成机器码。

JVM定义了控制java代码解释执行和具体实现的五种规格,他们是:

  • jvm指令系统

jvm指令系统同其他计算机的指令系统及其相似。java指令也是由操作码和操作数两部分组成。操作码为8位二进制数,操作数紧随操作码之后,其长度根据需要而不同。当操作数的长度大于8位时,会被分为两个以上的字节存放。它编码的方式和intel采用的方式不同,jvm是低字节放在高位,高位放在低字节中。java的8位操作码的长度使jvm最多有256条指令,java1.6及以上版本与使用了160多种操作码。

  • jvm寄存器(4中常用的)

    • pc:程序计数器
    • optop:指向操作数栈顶的指针
    • frame;指向当前执行方法的执行环境的指针
    • vars:指向将当前执行方法的局部变量区第一个变量的指针
  • jvm栈结构(java栈是JVM存储信息的主要方法)

    • 局部变量区
    • 运行环境区
    • 操作数区
  • jvm碎片回收堆

java类的实例所需的存储空间是在堆上分配的。

  • jvm存储区

jvm有两类存储区:常量缓冲池和方法区。

常量缓冲池:用于存储类名称、方法和字段名称以及串常量。

方法区:用于存储java方法的字节码。

这两种存储区域具体实现方式在jvm规格中没有具体说明,也就是说java应用程序的存储布局必须在运行过程中确定,依赖于具体平台的实现方式。

有一个类比的例子我觉得很形象:

如果把Java原程序想象成我们的C++原程序,Java原程序编译后生成的字节码就相当于C++原程序编译后的80x86的机器码(二进制程序文件),JVM虚拟机相当于80x86计算机系统,Java解释器相当于80x86CPU。在80x86CPU上运行的是机器码,在Java解释器上运行的是Java字节码。

了解到这里感觉jvm和最近学习的微机原理好像有异曲同工之妙。计算机在处理信息的时候也需要寄存器去存储数据或者存储指令地址等,有他的存储单元和运算单元。

运行时数据区区域

书里说java提供了一个相对安全的内存管理和访问机制,避免了绝大部分的内存泄漏和指针越界的问题。在android中如果对某些对象的使用不当的话就会出现内存泄漏,为此就带着这个问题去看看,java虚拟机内存的各个区域,为什么避免了这些问题。。。

java虚拟机所管理的内存将会包含以下几个运行时数据区域:

1. 程序计数器

程序计数器是一块较小的内存空间,没有规定OOM情况的区域且是线程私有的,有两个主要功能

  • 是当前线程所执行的字节码的行号指示器。

字节码解释器通过改变程序计数器的值来选取下一条将要执行的字节码指令(是跳转 循环还是。。。)

  • 为了线程切换后能够恢复到正确的执行位置

在操作系统中进程切换执行也需要记录进程当前执行的状态。这里的作用是可以类比的。因为java虚拟机的多线程是通过线程的轮流切换并分配处理器执行时间的方式实现的,因此需要记录当前线程运行的状态以便于下次从上次的运行状态开始执行。

如果线程正在执行的是java方法,计数器记录正在执行的虚拟机字节码指令的地址。如果正在执行的是native方法,计数器值为空。

2. java虚拟机栈

线程私有,且与线程的生命周期相同。描述的是java方法执行的内存模型

有一个需要了解的概念栈帧:是方法运行时的基础数据结构。

每个方法执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。一个方法从调用到执行,就是一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表

局部变量表存放了编译期可知的各种数据基本类型、对象引用类型和returnAddress类型(指向了一条字节码指令的地址)。

有两个特点:

  • long型和double型的数据(64位)会占用2个局部变量空间,其余的数据类型只占用1个。
  • 局部变量所需的内存空间在编译期间完成分配。当进入一个方法,这个方法在栈帧中需要分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部常量表的大小。

两种异常情况:

  • 线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverflowError异常
  • 虚拟机可以动态扩展,若扩展时无法申请到足够的内尺寸,抛出OutOfMemoryError异常

3. 本地方法栈

本地方法栈为虚拟机使用到的native方法服务,虚拟机栈为虚拟机执行的java方法服务。

本地方法区域会抛出StackOverFlowError异常和OutOfMemoryError异常。

4. java堆

java堆是java虚拟机所管理的最大的一块内存(对大多数应用),是被所有线程共享的一块内存区域,在虚拟机启动时创建。内存区域的唯一目的就是存放对象实例
几个特点:

  • 是各个线程的共享内存。
  • java堆是垃圾回收器管理的主要区域。
  • 从内存分配角度来看,线程共享的java堆可能划分出多个线程私有的分配缓冲区。
  • java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的就可以。

无论如何划分,无论哪个区域,存储的都是对象实例。划分目的就是为了更好的回收内存或者更快的分配内存。

5. 方法区

几个特点:

  • 是各个线程的共享内存区域
  • 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器翻译后的代码等数据
  • 不需要连续的物理内存和可以选择固定大小或者可扩展
  • 可以选择不实现垃圾回收机制。内存回收目标主要是针对常量池的回收和对类型的卸载。
  • 无法满足内存分配需求会抛出OOM。

6. 运行时常量池

几个特点:

  • 是方法区的一部分

Class文件有类的版本、字段、方法、接口等描述信息,还有一个就是常量池。

  • 常量池用于存放编译期生成的各种字面量和符号引用,这部分内容会在类加载后进入方法区的运行时常量池。还会把翻译出来的直接引用存储在运行时常量池。

java虚拟机对class文件的每一部分都有严格的格式要求,每个字节用于存储什么样的数据都必须符合规范才可以被虚拟机认可、装载和执行。

  • 对运行时常量池java虚拟机没有做任何细节要求,不同提供商实现的虚拟机可以按照自己的需要来实现内存区域。
  • 具备动态性(相对于class文件常量池)。运行期间也可以将新的常量放入池中。

java语言并不要求常量一定只要编译期才能产生,也就是并不是在class文件常量池中的内容才能进入方法区的运行时常量池。

  • 受到方法区的限制。无法申请内存时抛出OOM。

7. 直接内存

  • 并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。
  • 也可能导致OOM。
  • 不会受到java堆大小的限制,但是会受到本机总内存大小以及处理器寻址空间的限制。

对象的创建

对象的内存布局

对象的访问定位

这里看了很久的时间,发现其实做一个大纲就很好理解并且把他们串起来。


image

OutOfMemoryError异常

在android的日常开发中就很可能在写代码的过程中遇到OOM的问题。我遇到过OOM的场景就是在加载Bitmap的时候还有就是多线程的情况下,线程开的太多以至于在线程池中都无法挽救。

现在就先从底层来分析一下OOM出现的情况:

1. java堆溢出

java堆用于存储对象实例,只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量达到最大堆的容量限制后就会产生内存溢出异常。

在android可以通过下面的方法获得堆的大小,也就是每个程序可使用的内存的上限:

ActivityManager manager = (Activity)getSystemService(Context.ACTIVITY_SERVICE);
int heapSize = manager.getMemoryClass();//结果以MB为单位返回

有一个东西叫GC Roots到底是什么呢?

有一个算法叫做根搜索算法。是JVM用来*判断对象是否存活的算法,此算法的基本思路就是通过一系列的GC Roots对象作为起始点,从这些结点往下搜索,当一个对象和GC Roots不可达时,则该对象是无用的。

image

从上面的图可以看到5、6、7都到达不了GC Roots,所以会被回收掉。

可以作为GC Roots的对象

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中的类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI(即一般说的Native)中引用的对象

java堆内存的OOM异常是实际中经常会遇到的情况。当出现堆内存溢出时,为了解决,一般的手段是先通过内存映像分析工具对dump出来的堆转储快照进行分析。重点是确认内存中的对象是内存泄漏还是内存溢出。

  • 内存泄漏:可进一步通过工具查看泄漏对象到GC Roots的引用链。就能找到泄露对象是通过怎样的路径和GC Roots相关联并导致垃圾收集器无法自动回收他们。
  • 内存溢出:也就是不存在内存泄漏,内存中的对象确实都还活着。应该检查虚拟机的堆参数(-Xms或-Xmx),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行时期的内存消耗。(像service...)

虚拟机栈和本地方法栈溢出

  • 线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常
  • 虚拟机在扩展栈时无法申请到足够的内存空间,抛出OutOfMemoryError异常

方法区和运行时常量池溢出

就是在这两部分中的数据超过他们的范围就会溢出。有一个很有意思的例子

public class Test{
    public static void main(String[] args){
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);//true-jdk1.7以后 之前为false
        
        //在这之前java这个字符串在常量池中存在
        String str2 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str2.intern() == str2);//false 因为在new StringBuilder之前str2已经有指向常量池中的引用了
    }
}

首先去看了下intern这个方法是干嘛的,原来是返回字符串的对象。String的intern()方法会查找在常量池中是否存在一份equal相等的字符串,如果有则返回该字符串的引用,如果没有则添加自己的字符串进入常量池。

产生注释中结果不同的原因是:

  • 在jdk1.6中,intern方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在java堆上,所以必然不是同一个引用。
  • 在jdk1.7中,intern方法不会再复制实例,而是在常量池中记录首次出现的实例引用。

本机直接内存溢出

直接内存容量可通过-XX:MaxDirectMemorySize指定,如果不指定则默认与java堆最大值一样。

分配本机内存可以用两种方式:

用DirectByteBuffer

用过java NIO的话应该都用过ByteBuffer,用来作为消息的缓冲区,它其实是在直接内存开辟了一块空间。(看了虚拟机才知道 之前真的是蒙着头用啊 哈哈哈)

ByteBuffer BUFFER = ByteBuffer.allocateDirect(1*1024*1024);

获取UnSafe实例进行内存分配

public class DirectMemory{
    public static void main(String[] args){
        Field unsafeFiled = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessiable(true);
        Unsafe unsafe = (Unsafe)unsafeField.get(null);
        unsafe.allocateMemory(1*1024*1024);
    }
}

在jdk1.4中新加入了NIO类,引入了一种基于通道(channel)和缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这可以提高性能,因为避免了在java堆和native堆中来回复制数据。

所以直接内存区域的溢出,发生在可能忽略了分配直接内存的大小,在项目中使用了NIO的时候发生了OOM可以看是不是直接内存溢出的原因。

参考文章

百度知道 统领全文的作用

GC Roots 例子

java堆还是本地内存



喜欢的朋友记得点赞、收藏、关注哦!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值