Java虚拟机内存区域

运行时数据区

可以分成线程私有的和线程共享的区域。

线程私有的区域有:程序计数器,虚拟机栈,本地方法栈

线程共享的区域有:堆,方法区(JDK1.7中方法区的实现为永久代;在JDK1.8中,方法区放在了本地内存中,其实现为元空间),直接内存(不是运行时数据区)

程序计数器的作用:控制程序指令的进行,实现分支,跳转异常等逻辑;另一个作用是记录下一行字节码指定的地址,使得在多线程切换的时候能够找到正确的执行位置。

程序计数器会出现内存溢出吗?

内存溢出是指程序在使用某一块内存时,存放的数据内存大小超过了虚拟机所能提供的上限。

程序计数器是一个用于记录线程执行位置的寄存器,它通常不会出现内存溢出的情况。因为程序计数器的大小是固定的,通常为32位或64位,所以不会随着程序的执行而增长。因此,程序计数器不会出现内存溢出的情况。

Java虚拟机栈帧存放哪些内容?

Java虚拟机栈中由一个个栈帧组成,每次调用方法就有一个栈帧压入栈中,帧中存放的是局部变量表,操作数栈,帧数据。局部变量表的作用是存放局部变量;操作数栈存放临时数据;帧数据主要包含动态链接,方法出口,异常表的引用。

Java虚拟机内存区域_第1张图片

局部变量表:是一个数组,数组中的每个位置称为槽,long和double型变量占两个槽,其他类型占用一个槽,在方法的执行过程中存放所有的局部变量存放在这些槽中,包括方法参数,实例方法的this对象,方法体中声明的局部变量。同时为了节省空间,局部变量表中的槽可以复用,当某一局部变量失效,当前槽可以被再次使用。

操作数栈:用于存放方法执行过程的中间计算结果。

动态链接:当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。

Java虚拟机内存区域_第2张图片

方法出口:指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的 下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。

异常表:存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

本地方法栈

Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。

在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。

Java虚拟机内存区域_第3张图片

栈中可能抛出的错误

Java虚拟机栈可能抛出的两种错误,StackOverflowError(栈溢出错误)和OutOfMemoryError(内存溢出错误)。StackOverflowError通常发生当栈的内存大小不能动态扩展时,递归调用过多导致栈的深度超过了栈允许的最大深度,而OutOfMemoryError则是当栈的内存大小可以动态扩展时,如果虚拟机在扩展栈时,无法申请足够的内存空间,就会抛出这个错误。

Java堆

几乎所有的对象实例和数组都在堆中分配内存

为什么并不是所有的对象实例及数组在堆中分配内存?

原因在于:Java虚拟机引入了逃逸分析,会判断在方法中的对象是否会被外部方法使用。如果经过逃逸分析发现,方法中的对象不会被外部方法使用,那么这个对象就可能在栈中分配内存了。

public static StringBuffer craeteStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb;
    // 该StringBuffer对象sb就返回出去了,可能会被其他方法所使用

}

public static String createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
    //这个StringBuffer对象sb就没有被返回,因此不会被其他方法使用
    //返回的是String 对象
}

HotSpot 虚拟机为什么将堆内存分为新生代和老年代?

HotSpot 虚拟机之所以将堆内存分为新生代和老年代,是为了更好地适应对象的生命周期特征,以提高垃圾回收的效率和性能。这种划分主要是为了应对以下两个方面的情况:

1. 对象生命周期的特征:
大多数对象在创建后很快就变得不可达,然后被回收。这些对象属于短命对象,称为“朝生夕死”对象;一些对象则可能存活更长时间,甚至存活到整个应用程序的生命周期结束。这些对象属于长命对象,称为“长寿命”对象。
通过将堆内存分为新生代和老年代,可以更好地适应这些不同生命周期特征的对象,从而采用不同的垃圾回收策略和算法,以提高垃圾回收的效率。

2. 垃圾回收算法的选择:
新生代通常采用复制算法(Copying)来进行垃圾回收,因为大多数对象都是短命对象,复制算法能够高效地处理这种情况,同时避免产生内存碎片;老年代通常采用标记-清除或标记-整理算法,因为老年代的对象存活率较高,复制算法不太适用,而标记-清除或标记-整理算法能够更好地处理这种情况;

通过将堆内存分为新生代和老年代,HotSpot 虚拟机可以更好地适应对象的生命周期特征,采用不同的垃圾回收策略和算法,从而提高垃圾回收的效率和性能

方法区

方法区存放每个类的基本信息(元信息),包括类的元信息,运行时常量池,字符串常量池,在类的加载阶段完成。

Java虚拟机中类的元信息包括了类的名称、字段、方法、父类、接口、访问修饰符,运行时常量池、访问标志等信息。这些元信息在类加载的过程中被JVM加载到方法区(或者元空间)中,并且在整个程序的生命周期中被JVM使用。

运行时常量池:存放编译期间各种字面量和符号引用,字符串常量,类和接口的全限定名等。

字符串常量池:为了提升性能减少内存消耗针对字符串创建的一个区域,为了避免字符串的重复创建。

在JDK1.7时候字符串常量池被放在了堆中,这是因为方法区中的GC回收效率比较低,只有在整堆收集时候才会GC,将字符串常量池放在堆中,能够更加及时的回收字符串内存。

Java虚拟机内存区域_第4张图片

在JDK1.7中,方法区的实现是仍然是在JVM内存中,在JDK1.8中,方法区的实现是元空间,在直接内存中.在JDK1.7中,字符串常量池属于运行时常量池的一部分,在JDK1.8之后,字符串量池被放在了堆中.

Java虚拟机内存区域_第5张图片

为什么永久代替换成元空间呢?

动态调整大小:永久代的大小受JVM设置的固定大小限制,而元空间使用的是本地内存,永久代在Java 7及之前的版本中是固定大小的,而且不容易进行调整。这就意味着如果应用程序的类加载和卸载频繁,永久代可能会发生内存溢出。而元空间是在本地内存中分配的,它可以根据应用程序的需要动态调整大小,从而避免了永久代出现的内存溢出问题。在JDK1.7中通过-XX:PermSize来设置永久代初始分配空间,默认值是20.75M,

    -XX:MaxPermSize来设定永久代最大可分配空间。32位机器默认是64M,64位机器默认是82M

    当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError:PermGen space

而在JDK1.8中,通过-XX:MetaspaceSize和-XX:MaxMetaspaceSize来设置,windows中MetaspaceSize默认值是20.75M,MaxMetaspaceSize默认值是-1,表示没有限制

内存位置:永久代是在堆内存中分配的,而元空间是在本地内存中分配的。这意味着元空间不再占用堆内存的空间,从而可以更好地利用堆内存,提高应用程序的性能。

垃圾回收:永久代的垃圾回收与堆内存的垃圾回收是交织在一起的,这可能会导致垃圾回收的开销较大。而元空间的垃圾回收是与堆内存的垃圾回收是分离的,因此可以更好地控制元数据的生命周期,减少垃圾回收的开销。

静态变量的存储

在JDK1.6之前的版本,静态变量存放在方法区中,在JDK7及之后的版本,静态变量存放在堆中的Class对象中.

总结

Java虚拟机内存区域_第6张图片

Java虚拟机内存区域_第7张图片

Java虚拟机内存区域_第8张图片

Java虚拟机中的对象 

Java对象创建的过程

在 Java 虚拟机中,对象的创建过程经历了以下几个步骤:类加载检查,分配内存,初始化零值,设置对象头,执行构造方法,返回对象引用.

类加载检查:在创建对象之前,首先需要加载对象的类。如果该类尚未被加载,Java 虚拟机会先进行类加载操作,包括加载、链接(验证、准备、解析)和初始化阶段。

分配内存:在类加载完成后,Java 虚拟机会为对象分配内存空间。根据对象的大小,虚拟机会在堆(Heap)中分配一块足够的内存空间。

初始化零值:分配到的内存空间会被初始化为零值。这一步确保了对象的实例变量在创建时已经具有默认的初始值。

设置对象头:在分配内存后,Java 虚拟机会在对象的内存空间中设置对象头信息,包括对象的哈希码、锁状态、垃圾回收信息等。

执行构造方法:在内存分配和对象头设置完成后,虚拟机会调用对象的构造方法来进行对象的初始化,为对象的实例变量赋予特定的初始值。

返回对象引用:最后,对象创建完成,并且构造方法执行完毕后,将对象的引用返回给程序,可以通过该引用来访问和操作对象。

在对象创建过程中是怎么分配内存的呢?

Java 虚拟机通常会使用以下两种方式来分配对象的内存:

指针碰撞(Bump-the-Pointer):这种分配方式通常是在堆内存的某个位置设置一个指针,指向当前可用的内存空间。当需要分配对象时,虚拟机会将指针向后移动,指向下一个可用的内存空间,然后将对象分配在该位置。这种方式要求堆内存中的空闲空间和已分配空间是连续的,适用于使用垃圾回收器中的新生代和老年代的收集器。使用这种方法分配内存的垃圾回收器是:Serial,ParNew

Java虚拟机内存区域_第9张图片

空闲列表(Free List):这种分配方式会维护一个空闲列表,记录了堆内存中可用的内存空间。当需要分配对象时,虚拟机会在空闲列表中寻找一个足够大的内存空间,然后将对象分配在该位置。这种方式适用于堆内存中的内存分配比较复杂的情况,例如使用了不同大小的对象分配策略或者使用了压缩指针等技术。使用这种方式的垃圾回收器是:CMS

Java虚拟机内存区域_第10张图片

给对象分配内存会出现什么问题吗?

在 Java 中,内存分配过程可能存在并发安全问题,主要表现在以下两个方面:

线程安全问题:在多线程环境下,多个线程可能同时请求分配内存,如果没有采取措施进行同步,就可能会出现多个线程同时分配同一个内存区域的情况,导致数据的不一致或者内存泄漏等问题。

那应该怎么解决分配内存带来的问题呢?

线程同步:Java 虚拟机会使用线程同步机制来确保内存分配的原子性和可见性。例如,使用 synchronized 关键字或者使用 CAS 操作等机制来保证内存分配的线程安全性。

TLAB:TLAB(Thread Local Allocation Buffer)是一种用于提高多线程环境下对象分配效率的技术。在Java虚拟机中,每个线程都有自己的TLAB,用于分配对象实例的内存空间。TLAB的作用是为每个线程分配一块小的内存区域,当线程需要分配对象时,它会先在自己的TLAB中分配内存,而不是直接在堆上进行分配。这样可以减少线程之间的竞争,提高对象分配的效率。

TLAB的使用可以减少线程之间的内存分配竞争,减少锁的竞争,从而提高了对象分配的效率。同时,由于每个线程都有自己的TLAB,因此不需要进行线程间的同步操作,减少了线程间的竞争和锁的开销。

初始化零值是什么意思呢?

初始化零值指的是在为对象分配内存后,虚拟机会将该内存空间中的每一个字节都设置为零值。这是为了确保对象的实例变量在创建时已经具有默认的初始值。

Java 中的基本数据类型和引用类型的默认初始值如下:

对于基本数据类型(如int、double、boolean等),它们在分配内存后会被初始化为对应的零值,例如0、0.0、false等。

对于引用类型(如对象引用),它们在分配内存后会被初始化为null,表示没有指向任何对象。

这种初始化零值的操作是Java虚拟机在对象创建过程中的一部分,它确保了对象在创建后的初始状态是可预测的。当然,在对象的构造方法执行后,这些变量的值会根据构造方法中的赋值操作被修改为相应的初始值。

对象的内存布局

Java虚拟机内存区域_第11张图片

对象头:存储对象自身的运行时数据(哈希码,锁状态,GC分代年龄)以及类型指针,即对象指向它的类的元数据的指针,Java虚拟机通过这个指针确定该对象是拿一个类的实例。

实例数据:存储对象实际字段,包括对象的属性和方法。

对齐填充:由于Java虚拟机要求对象的起始地址必须是8字节的整数倍,因此为了满足对齐的需要,可能在实例数据后填充一些字节。使得对象的大小是8字节的整数倍。

补充知识:哈希码是对象的特定标识,是一个32位的整数;

GC分代年龄:通过对象在新生代中经历的垃圾回收次数计算。当对象在Eden区创建时,初始分代年龄为0;每经历一次Minor GC,如果对象仍然存活那么对象的GC分代年龄+1;当分代年龄达到阈值(默认是15,通过 XX:MaxTenuringThreshold控制),对象会晋升到老年代。

对象的访问定位方式

Java对象访问有两种方式,句柄访问和直接指针访问。

在直接指针访问方式中,Java栈中的局部变量表中的referece存储的是对象的内存地址,可以直接通过reference来访问。

在句柄访问方式中,Java的堆中会划分一个区域作为句柄池,其中包含了对象的实际内存地址及对象的类型信息。而reference存储的就是对象的句柄地址,通过句柄地址找到真实的内存地址。

这两种方式各有利弊,在句柄访问方式中,因为reference存储的是句柄地址,在对象移动时候,不用修改reference,只需移动句柄中的指针;此外由于referece没有直接暴露对象的内存地址,因此更加安全;缺陷是多了一次定位的开销。

默认情况下,Java虚拟机使用的是句柄访问方式。

直接指针访问:

参考文章:深入理解Java中的逃逸分析_hollischuang 深入分析java的编译原理-CSDN博客

未完结 

你可能感兴趣的:(java,jvm,开发语言)