本篇图文深入讨论java的内存分配。当然,这里所说的深入,并不是指长篇大论,而是就事论事重点深入介绍java的内存分配原理机制。
在理解java如何为变量或者对象分配内存之前,我们先来了解一个问题:为什么需要分配内存?
为什么需要分配内存?
为什么需要分配内存?相信不少人会这么问。这得先从计算机的硬件组成结构说起。
1. 服务器的硬件組成
这里其实就是泛指计算机,但是因为java程序大部分都是run在服务器的,所以本文就以服务器代称。服务器的硬件组成如下图所示:
这里所包括的只是计算机最核心的一些硬件组成:CPU,内存,disk,并不包含其他外设,核心一词对于java程序来说,就是有了这些结构,java程序就能够运行起来,少了这些硬件它就run不起来。
这些结构里面,CPU和内存都是稀缺资源,稀缺的言下之意就是资源有限,不是你想用多少就能给你多少。所以,就需要提前进行内存的分配,java提前进行内存分配也是好处多多的,不信你看:
2. java进行内存分配的好处
- 为对象提供存储位置
当创建一个 Java 对象时,需要有一块确定的内存空间来存放这个对象的实例数据和对象头信息。如果不先分配内存,就无法确定对象的存储位置,也就无法进行后续的对象初始化操作。例如创建一个自定义的Person对象,Person person = new Person();,如果没有预先分配好内存,这个对象就无处安放,无法在程序中被使用和操作。 - 确保数据完整性
内存分配过程可以确保对象在创建时有一个连续的、合适大小的内存空间。这样可以保证对象的各个成员变量和状态能够正确地存储在内存中,避免数据混乱和错误。如果在对象创建过程中随意地存储数据而没有事先分配好内存,可能会导致数据覆盖、丢失或者存储不完整的情况发生。 - 支持垃圾回收机制
通过先分配内存,Java 的垃圾回收机制可以更好地管理内存。当对象被创建并分配内存后,垃圾回收器可以跟踪这些对象的引用情况。一旦对象不再被引用,垃圾回收器就可以识别出这些无用对象,并回收它们所占用的内存空间,从而提高内存的利用率。如果没有明确的内存分配过程,垃圾回收器将难以确定哪些内存区域是正在使用的对象,哪些是可以回收的无用空间。 - 实现多线程安全
在多线程环境下,内存分配的过程可以通过适当的同步机制来保证线程安全。例如,在对象创建时,可以使用同步块或者原子操作来确保多个线程不会同时尝试分配同一块内存,从而避免内存冲突和数据不一致的问题。这样可以保证程序在多线程环境下的稳定性和可靠性。 - 有利开发和调优
这种明确的内存分配方式也使得开发人员更容易理解和掌握 Java 程序的内存使用情况,便于进行性能优化和调试。
3. java JVM内存与服务器内存的关系
正如上图所示,实际的java程序基本都涉及到多线程,每个CPU核心都可以访问共享变量,但是每个线程都需要自己的工作内存来处理数据,多线程的执行最终都会映射到硬件处理器上执行。但Java JVM内存模型和硬件内存架构并不完全一致,硬件内存只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一个抽象出来的概念,并不实际存在。不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中(一些热数据存储在CPU缓存或者寄存器中),所以,JVM的内存分配其实就是一种虚拟的概念,说得简单直白点就是为了提高程序的运行效率而预先分配好的一种内存使用流程和机制,不像硬件那样,并不具有严格的框架约束。
4. java JVM的内存结构
- JVM由三部分组成:类装载子系统,运行时数据区(内存),字节码执行引擎;其中类装载子系统把类加载进来, 放入到虚拟机中,采用双亲委派机制,把类加载进来放入到JVM虚拟机中。然后, 字节码执行引擎去虚拟机中读取数据。
- 运行时数据区主要由5个部分构成: 堆,栈,本地方法栈,方法区,程序计数器,下文会详细介绍这5部分。
- 如上图所示,JVM的上述三部分密切配合工作。
Java内存分配的基本机制
Java内存分配机制是基于堆栈式的内存管理机制。为了提高运算效率,JVM内存被划分为不同的区域,每个区域都有其特定的用途和管理方式。这些区域主要包括方法区(Method Area)、堆区(Heap)、栈区(Stack)、本地方法栈(Native Method Stack)以及程序计数器(Program Counter Register)。
1. 方法区:
方法区是JVM中的一块内存区域,用于存储类的元数据(metadata)信息,如类的描述符、字段、方法等信息。在JDK 8及以前,方法区通常被实现为永久代(PermGen space),但在JDK 8及以后版本中,方法区被元空间(Metaspace)所取代,使用本地内存来存储类的元数据,从而避免了永久代在Java堆中的内存限制。
2. 堆区:
堆区是JVM管理的最大一块内存空间,用于存放对象实例和数组。堆内存由JVM自动管理,程序员不需要手动分配和释放。堆内存细分为新生代(Young Generation)和老年代(Old Generation)。新生代又包括Eden区和两个Survivor区(S0和S1)。新创建的对象首先被分配在Eden区,当Eden区满时,会触发一次Minor GC(年轻代GC),将存活的对象复制到其中一个Survivor区,并清空Eden区。经过多次Minor GC后,存活的对象会被移动到老年代。老年代主要存放生命周期较长的大对象。当老年代内存不足时,JVM会触发Major GC或Full GC,这类GC通常代价较高,会导致系统暂停时间较长。
3. 栈区:
栈区是线程私有的,用于存储局部变量、基本数据类型变量的值、对象的引用等。每个方法被调用时,JVM会为该方法在栈中分配一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。当方法执行完毕后,栈帧会被销毁,局部变量等也会被自动释放。栈内存的生命周期与线程同步,每个线程都有自己的栈内存。
5. 本地方法栈:
本地方法栈与栈的作用非常相似,主要区别在于本地方法栈是为执行本地方法(Native Method)服务的。本地方法一般是用C/C++编写的,并且被编译为本地代码。与栈一样,本地方法栈也是线程私有的。
6. 程序计数器:
程序计数器是线程私有的,用于记录当前线程所执行的字节码的行号指示器。它是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,因此为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。
Java内存分配策略及优化技术
1. Java内存分配策略
Java内存分配策略主要包括静态存储分配、栈式分配和堆式分配。
- 静态存储分配:
–在编译时就能确定每个数据目标在运行时刻的存储空间需求,因而在编译时就可以给它们分配特定的存储空间。
–静态存储分配要求程序中不允许有可变数据结构的存在(比如数组、链表),也不允许有嵌套或者递归的结构出现,因为它们会导致编译程序无法计算准确的存储空间需求。 - 栈式分配(动态存储分配):
–栈式分配由一个类似于堆栈的运行栈来实现。
–在栈式存储方案中,程序对数据区的需求在编译时是完全未知的,只有到运行的时候才能够知道。但是规定在运行中进入一个程序模块时,必须知道该程序模块所需的数据区大小才能够为其分配内存。
栈式存储分配按照先进后出的原则进行分配。 - 堆式分配:
–专门负责在编译时或运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例。
–堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放。
2. Java内存分配的特点
- 堆内存的特点:
可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的。
Java的垃圾收集器会自动收走这些不再使用的数据。但由于要在运行时动态分配内存,存取速度较慢。 - 栈内存的特点:
–存取速度比堆要快,仅次于寄存器。
–栈数据可以共享。但栈中的数据大小与生存期必须是确定的,缺乏灵活性。
3. Java内存优化技术
- 对象池技术:对于频繁创建的短生命周期对象,如字符串,可以使用对象池技术减少对象创建的开销。例如,String Pool通过缓存不可变字符串,减少了相同字符串的重复创建。
- 逃逸分析:逃逸分析是JVM的一个优化技术,用于检测对象是否逃逸出方法。如果对象未逃逸,JVM可以将其分配在栈上而不是堆上,从而避免不必要的垃圾回收。
- 减少对象创建:尽量避免在循环或高频率调用的地方频繁创建对象。可以通过使用缓存、对象复用等方式来减少对象的创建次数和垃圾回收压力。
- 合理规划数据结构:大对象(如大型数组或集合)直接分配在老年代。频繁分配大对象可能导致老年代快速充满,触发Full GC。因此,需要合理规划数据结构,尽量避免不必要的大对象分配。
- 其他内存优化技巧:
–通过调整JVM参数,可以对堆内存的分配机制进行优化,例如调整Eden区和老年代的比例,或者调整垃圾收集器的行为。
–合理设置元空间的大小可以避免频繁的内存回收,提高JVM性能。
–注意避免内存泄漏,如静态集合长时间持有大对象的引用、长生命周期对象不再使用但仍然占用内存、内部类持有外部类的引用等。
Java如何确定对象的内存分配大小?
在 Java 中,确定对象的内存分配大小主要通过以下几个方面:
1. 对象头
对象头占用一定的内存空间。在 64 位的 JVM 中,对象头一般占用 12 字节(开启指针压缩,默认开启)或 16 字节(未开启指针压缩)。对象头中包含了对象的哈希码、分代年龄、锁状态标志等信息。
2. 实例数据
- 基本数据类型成员变量:
不同的基本数据类型占用不同的内存空间。例如,boolean类型占用 1 个字节;byte类型占用 1 个字节;short类型占用 2 个字节;int类型占用 4 个字节;long类型占用 8 个字节;float类型占用 4 个字节;double类型占用 8 个字节;char类型占用 2 个字节。如果对象中有多个基本数据类型的成员变量,它们会按照各自的类型大小依次排列在内存中。 - 引用数据类型成员变量:
引用数据类型(如对象引用、数组引用等)在 64 位 JVM 中一般占用 8 个字节(开启指针压缩)或 16 字节(未开启指针压缩)。引用实际上是指向堆中实际对象或数组的内存地址。 - 对齐填充:
为了满足某些硬件架构的内存对齐要求,对象的内存大小可能会进行填充。例如,如果对象的实例数据部分大小不是 8 字节的整数倍,可能会填充一些字节使得对象的总大小是 8 字节的整数倍。
3. 数组对象
如果是数组对象,除了对象头之外,还需要考虑数组元素的类型和数量。例如,一个包含 int 类型元素的数组,每个元素占用 4 个字节,再加上对象头的大小,就是整个数组对象的内存分配大小。
其实在实际开发中,一般不需要精确地知道对象的内存大小,但了解对象内存分配的大致原理可以帮助你更好地理解 Java 程序的内存使用情况,进行性能优化和故障排查。
不同的数据类型内存具体是怎么分配的?
1. 基本数据类型
基本数据类型(如int、short、long、byte、float、double、boolean、char)的内存分配相对简单。这些类型的数据通常直接存储在栈内存中。栈内存的特点是存取速度快,但大小固定且生存期有限。基本数据类型的变量存的是字面值,不是类的实例,这些字面值的数据由于大小可知、生存期可知,出于追求速度的原因,就存在于栈中。
2. 对象类型
对象类型的内存分配相对复杂。当创建一个对象时,首先会在栈内存中为对象的引用变量分配空间,然后在堆内存中为对象的实例分配空间。堆内存是Java程序中用于存储对象的主要内存区域,它的大小是可扩展的,因此可以在运行时动态分配空间。例如,创建一个Integer对象时,JVM会首先在堆内存中为其分配空间,并调用Integer类的构造函数来初始化对象。之后,返回一个指向该对象的引用,该引用存储在栈内存中,以便后续通过该引用来访问和操作对象。如下图
3.字符串
字符串在Java中是一个特殊的对象类型,但它有自己的内存分配机制。字符串字面量(例如String str = “abc”;)会被存储在字符串常量池中。字符串常量池是Java中的一个特殊内存区域,用于存储字符串字面量。当创建一个字符串字面量时,JVM会首先检查字符串常量池中是否已经存在相同内容的字符串。如果存在,则返回该字符串的引用;如果不存在,则在字符串常量池中创建一个新的字符串对象,并返回其引用。
使用new关键字创建的字符串对象(例如String str = new String(“abc”);)则不会在字符串常量池中创建,而是在堆内存中创建一个新的字符串对象。这意味着每次使用new关键字创建字符串对象时,都会占用额外的内存空间。
4.数组
数组在Java中是一种基本数据结构,用于存储一系列相同类型的数据。数组的内存分配是一块连续的内存空间,用于存储数组中的所有元素。数组的内存模型可以分为两个部分:数组对象本身和数组元素。数组对象是数组的一个实例,包含数组的长度和数组元素的总大小。数组元素是数组中的单个数据项,它们存储在连续的内存空间中。
对于基本数据类型的数组,数组元素直接存储在数组对象的内存区域之后。对于对象类型的数组,数组元素也是存储在连续的内存空间中,但每个元素都是一个对象。
本篇博文个人是奔着难易适度的原则去写的,可能难免有些错误,请各位大佬指正。
码字不易,宝贵经验分享不易,请各位支持原创,转载注明出处,多多关注作者,家人们的点赞和关注是我笔耕不辍的动力。