关于内存管理其实是项目开发当中一件非常重要的事情,因为平时在写Java,对内存的分配是不敏感的,但一旦导致问题,没有做过相关的总结问题的排查也是非常困难,C++的话,如果不引起关注,会产生相当多的OOM问题,所以在这里我们介绍与探讨一下Java与C++语言关于内存管理机制和它们的异同。

Java内存管理机制

什么是JVM

在介绍Java内存管理机制的时候,我们是绕不开JVM的,所以在这里,先初步介绍一下JVM。首先我们知道,Java有一个很大的特点就是跨平台运行,那么这个是怎么实现的呢?Java在针对不同的平台推出了不同的JVM,而编写好的语言并不是直接在设备平台上运行,而是在JVM中运行,这样便能让Java拥有跨平台运行的特点,而JVM是Java Virtual Machine的缩写,它是一个虚拟出来的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。

JVM有自己的硬件架构,比如处理器、堆栈、寄存器等,还有对应分指令系统。

主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存,每条线程都有自己的工作内存,线程的工作内存中保存了该线程所使用到的变量的内存副本。

不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量的传递都是要通过主内存来完成的。

JVM是怎么划分内存的?

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则是用户线程的启动和结束从而建立和销毁,Java虚拟机所管理的内存将会包括以下几个运行区域。

img

线程私有的数据区域
程序计数器

程序计数器有以下三格特点:

  • 较小

程序计数器是一块较小的内存空间,它的作用可以看做是当前线程所指向的字节码的行号指示器,在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取吓一跳需要指向字节码的指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 线程私有

由于Java虚拟机的多线程使用过线程轮询来分配处理器的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程的指令,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的程序计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

  • 无异常

如果线程正在执行的是一个Java的方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是Native方法,这个计数器值则为空。此内存区域是唯一一个在Java虚拟机规范中没有任何OOM情况的区域。

虚拟机栈
  • 线程私有

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同

  • 描述Java方法执行的内存模型

虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧,栈帧是方法运行期的基础数据结构,用于存储局部变量表、操作栈、动态链接、方法出口等信息,每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈从入栈到出栈的过程。

  • 异常

在Java虚拟机中,对虚拟机栈规定了下面两种异常

  1. StockOverflowError

当执行Java方法是会进行压栈的操作,在栈帧会保存局部变量,操作栈的方法出口等信息。JVM规定了栈的最大深度,如果线程请求执行方法时栈的深度超过了最大规定的深度,就会抛出栈溢出异常。

  1. OutOfMemoryError

如果虚拟机在扩展时无法申请到足够的内存,就会抛出内存溢出异常。

本地方法栈

本地方法栈的作用与虚拟机非常相似,它有下面两个特点

  • 为native服务

本地方法栈与虚拟机栈的区别是虚拟机栈为Java服务,而本地方法栈为native方法服务

  • 异常

与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常

所有线程共享的数据区域
Java堆

Java堆(Java Heap)也就是实例堆,它有以下四个特点:

  • 最大

对于大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。

  • 线程共享

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。

  • 存放实例

此内存区域的唯一目的就是存放对象的示例,几乎所有的对象实例都在这里分配内存。这一点虚拟机中的规范描述是:所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都在堆上分配内存也渐渐的变得没有那么“决对”了。

  • GC

Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”(Carbage Collected Heap)。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代。如果从内存分配的角度看,线程共享的 Java 堆中可能划分多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过,无论如何划分,都与存放内容无关,无论哪个区域,存储的都依然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。

方法区

方法区存储的是已经被虚拟机加载的数据,它有以下三个特点:

  • 线程共享

方法区域和Java堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的数据。

  • 存储的数据类型

类的信息

常量

静态变量

及时编译器编译后的代码

  • 异常

方法区的大小决定履历系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样抛出内存溢出异常 OutOfMemoryError。

方法区又可以分为运行时常量池和直接内存两部分

  1. 运行常量池

运行时常量池(Running Constant Pool)是方法区的一部分。

Class 文件中处了有类的 版本、字段、方法和接口等描述信息,还有一项信息就是常量池(Constant Pool Table)。

常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时常量池受到方法区内存的限制,当常量池无法再申请到内存时就会抛出 OutOfMemoryError 异常。

  1. 直接内存

直接内存(Direct Memory)有以下四个特点:

a)在虚拟机数据区外

直接内存不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

b)直接分配

在 JDL1.4 中新加入的 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用操作,这样能避免在 Java 堆和 native 堆中来回复制数据。

c)受设备内存大小限制

直接内存的分配不会受到 Java 堆大小的限制,但是会受到设备总内存(RAM 以及 SWAP 区)大小以及处理器寻址空间的限制。

d)异常

直接内存的容量默认与 Java 对的最大值一样,如果超额申请内存,也可能导致 OOM 异常出现。

C++内存管理机制

内存管理是C++最有争议的问题,C++可以从中获得了更好的性能,更大的自由,但内存管理在C++中无处不在,内存泄漏几乎在每个C++程序中都会发生,因此要想成为C++高手,内存管理一关是必须要过的,除非放弃C++,转到Java或者.NET,他们的内存管理基本是自动的,当然你也放弃了自由和对内存的支配权,还放弃了C++超绝的性能。

在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

img

  • ,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
  • ,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
  • 自由存储区,就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。
  • 全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。
  • 常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。

控制C++的内存分配

 在嵌入式系统中使用C++的一个常见问题是内存分配,即对new 和 delete 操作符的失控。

  具有讽刺意味的是,问题的根源却是C++对内存的管理非常的容易而且安全。具体地说,当一个对象被消除时,它的析构函数能够安全的释放所分配的内存。

  这当然是个好事情,但是这种使用的简单性使得程序员们过度使用new 和 delete,而不注意在嵌入式C++环境中的因果关系。并且,在嵌入式系统中,由于内存的限制,频繁的动态分配不定大小的内存会引起很大的问题以及堆破碎的风险。

  作为忠告,保守的使用内存分配是嵌入式环境中的第一原则。

  但当你必须要使用new 和delete时,你不得不控制C++中的内存分配。你需要用一个全局的new 和delete来代替系统的内存分配符,并且一个类一个类的重载new 和delete。

  一个防止堆破碎的通用方法是从不同固定大小的内存持中分配不同类型的对象。对每个类重载new 和delete就提供了这样的控制。

堆和自由存储区的区别与联系

从技术上来说,堆(heap)是C语言和操作系统的术语。堆是操作系统所维护的一块特殊内存,它提供了动态分配的功能,当运行程序调用malloc()时就会从中分配,稍后调用free可把内存交还。而自由存储是C++中通过new和delete动态分配和释放对象的抽象概念,通过new来申请的内存区域可称为自由存储区。基本上,所有的C++编译器默认使用堆来实现自由存储,也即是缺省的全局运算符new和delete也许会按照malloc和free的方式来被实现,这时藉由new运算符分配的对象,说它在堆上也对,说它在自由存储区上也正确。但程序员也可以通过重载操作符,改用其他内存来实现自由存储,例如全局变量做的对象池,这时自由存储区就区别于堆了。我们所需要记住的就是:

  • 堆是C语言和操作系统的术语、是操作系统维护的一块内存,而自由存储是C++中通过new与delete动态分配和释放对象的抽象概念。堆与自由存储区并不等价。
  • new所申请的内存区域在C++中称为自由存储区。藉由堆实现的自由存储,可以说new所申请的内存区域在堆上。

堆和栈的区别

  • 管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。
  • 空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,默认的栈空间大小是1M(VS2017 项目-属性-链接器-系统可以修改)。
  • 碎片问题:对于堆来讲,频繁的malloc/free势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。栈是先进后出的队列,以至于永远都不可能有一个内存块从栈中间弹出。
  • 分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。
  • 分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

    两种语言的对象访问机制

Java

在Java语言中,对象访问是最普通的程序行为,虽然是十分简单且常见的访问,但也会涉及到Java栈、Java堆、方法区这三个最重要的内存区域之前的关联关系

以一个简单的本地变量引用

1
Person person = new Person

这里Person person表示一个本地引用,存储在JVM栈的本地变量表中,表示一个reference类型数据;

而new Person()则作为示例对象数据存储在堆中;

堆中还记录了Object类的类型信息(接口、方法、field、对象类型等)的地址,这些地址所执行的数据存储在方法区中,在Java虚拟机规范中,对于通过reference类型引用访问具体对戏那个的方式并未做规定,目前主流的实现方式只要有两种:

  1. 通过句柄访问:通过句柄访问的实现方式中,JVM堆中会专门有一块区域用来作为句柄池,存储相关句柄所执行的实例数据地址(包括在堆中地址(实例)和方法区中的地址(数据类型))。这种实现方法由于用句柄表示地址,因此十分稳定。
  2. 通过直接指针访问的方式:reference中存储的就是对象在堆中的实际地址,在堆中存储的对象信息中包含了在方法区中的相应类型数据,这种方法有事就是速度快,在HotSpot虚拟机中用的就是这种方式。
C++

在C++中,编译器把内存分为三个部分,有四种方法可以产生一个对象。

  1. 第一种方法是在堆栈中产生

这是在栈中以及为它分配了一个空间存放所有的成员变量,调用这个对象的成员变量和成员函数时用“.”操作符,对于局部对象,使用完后不需要手动释放,该类析构函数会自动执行。

1
2
3
4
5
void MyFunc()
{
ClassName ObjName1 = ObjName1(parameter01);
ClassName ObjName1, ObjName2(parameter01);
}
  1. 第二种方法是在堆中产生

用这种方法创建的对象,内存分配到堆里(Heap)。一般使用“->” 调用对象的方法。箭头操作符”->”将解引用(dereferencing*)和成员使用(member access.)结合起来,在堆中的对象不会自动删除,内存不会自动回收,所以new一个对象使用完毕,必须调用delete,释放内存空间。也就是说,new和delete必须成对出现。

1
2
3
4
5
6
7
void MyFunc()
{
ClassName *obj1 = new ClassName();
ClassName *obj2 = new ClassName(parameter);
delete obj1;
delete obj2;
}
  1. 第三种方法时产生一个全局对象

对于全局对象,在main()之前分配内存。 一般的全局对象在程序的其他文件中可以通过关键字extern来调用,而static声明的全局变量则只能在本文件中使用。

1
CFoo foo; 
  1. 第四种则是产生一个静态局部对象

从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static 变量。

1
2
3
4
5
void MyFunc()

{
static CFoo foo;
}

注意:栈中内存的分配和管理由操作系统决定,而堆中内存的分配和管理由管理者决定。

我们需要的内存很少,你又能确定你到底需要多少内存时,用栈。当你需要在运行时才知道你到底需要多少内存时,请用堆。

Java内存管理机制
C++内存管理
c++创建对象(四种方法)和内存分析