一文掌握java对内存空间的划分

对比java与C/C++开发

使用C和C++开发软件,程序运行的内存是要程序员自己管理的。
有好有坏,好处自己能细致的控制内存,坏处写不好就要发生内存溢出错误,程序就会崩溃。

Java设计之初就有简化C的意图,所以它把内存的管理权限放到了JVM,让JVM自动处理,简化了开发。
但同样有好有坏,好处我们写Java程序基本上不用管内存的分配问题,
坏处,程序运行起来后对内存的使用的详细情况,我们不能精准控制了,没出错就罢了,一旦发生内存溢出,程序员就抓瞎了,尤其是少数人访问系统没有问题,访问人数暴增后,就出问题,这个时候,如果没有对JVM运行时的内存分配、管理的机制很熟悉的话,就完蛋了!
所以,JVM运行时的内存管理机制是我们必须要学会的!

内存划分

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。
这些区域有各自的用途和各自的生命周期,
有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:
在这里插入图片描述

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器
在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,
它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
程序计数器在JVM中占用的空间很少,但作用很大。也是JVM规范里唯一规定没有任何OutOfMemoryError情况的区域。

Java虚拟机栈

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程隔离的,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的线程内存模型:
每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)。
栈帧是一个用于存储局部变量表、操作数栈、动态连接、方法出口等信息是一个数据结构。
每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

栈和后面讲的堆,是我们Java程序员,最关心的内存区域。
一般情况下,我们笼统地认为我们定义的局部变量,如String s = new String(“内容在堆里,s变量在栈里”);

实质上,更具体讲,s是存放在执行s所在的方法的某条具体的线程对应的JVM栈里的某个栈帧里的局部变量表里!

局部变量表存放内容:

  1. 编译期可知的各种Java虚拟机基本数据类型(boolean、byte、 char、 short、 int、 float、 long、 double) 。
  2. 对象引用:它并不等同于对象本身,可能是一个指向对象起始地址的弓|用指针, 也可能是指向一个代表对象的句柄或者其他与此对象相关的位置。也可以理解为是一个地址。
  3. returnAddress类型 (指向了一条字节码指令的地址)。

局部变量表空间是用一个叫做局部变量槽(slot)来表示的。

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

注意,这里说的”大小” 是指变量槽的数量,虚拟机真正使用多大的内存空间,
譬如按照1个变量槽占用32个比特(b)、64个比特(b),或者更多,是完全由具体的虚拟机实现自行决定的事情。

在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:

  1. 如果线程请求的栈深度大于虚拟机所允许的深度(深度由栈大小,及栈中局部变量表大小决定),将抛出StackOverflowError异常。
  2. 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

注意:HotSpot虚拟机的栈容量是不可以动态扩展的,以前的Classic虚拟机倒是可以。所以在HotSpot虚拟机上是不会由于虚拟机栈无法扩展而导致OutOfMemoryError异常,只要线程申请栈空间成功了就不会有OOM,但是如果申请时就失败,才会出现OOM异常。

本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自实现它甚至有的Java虚拟机(譬如HotSpot虚拟机) 直接就把本地方法栈和虚拟机栈合二为一。
所以记住:HotSpot虚拟机是没本地方法栈,虽然-Xoss参数是设置本地方法栈大小存在,但是是没用的,栈的容量大小只有-Xss 参数设置。
与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

Java堆

对于Java应用程序来说,Java堆(Java Heap)是虛拟机所管理的内存中最大的一块。
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
此内存区域的唯一目的就是存放对象实例, Java世界里“几乎” 所有的对象实例都在这里分配内存。
在《Java虚拟机规范》中对Java堆的描述是:”所有的对象实例以及数组都应当在堆上分配”。

Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”。
从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,
所以Java堆中经常会被进一步划分成”新生代””老年代””永久代”“Eden空间””From Survivor空间”"To Survivor空间”等等

Java堆既可以被实现成固定大小的,也可以是可扩展的,
不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定其空间的大小)。

方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,
虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,
但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

说到方法区,不得不提一下”永久代 ”这个概念,尤其是在JDK 8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为”永久代”(PermanentGeneration),或将两者混为一谈。原因是当时的HotSpot虚拟机设计团队选择把垃圾收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。
但是对于其他虚拟机实现,譬如JRockit、J9来说,是不存在永久代的概念的。

JDK8 内存划分
在这里插入图片描述

JDK1.7中,符号引用(Symbols)转移到了native heap;字符串常量池(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。

原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。
但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,

  1. 这种设计导致了Java应用更容易遇到内存溢出的问题和性能问题。
  2. 类及方法的信息等比较难预估其大小,永久代要求配置参数-XX: MaxPermSize,即使不设置也有默认大小因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  3. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

到了JDK8,完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space) 来代替了。

这样就没有参数-XX: MaxPermSize的大小限制,减少了OOM的机会。
当然也不是无限大哈,受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,
当方法区满足不了程序的空间需求,OOM仍然会出现的。

运行时常量池

前面刚刚讲了JVM规范中描述方法区应该存储的数据:被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存。
常量,就进一步涉及一个叫“常量池”的概念

常量池主要可以分为以下几种:

1.静态常量池:即*.class文件中的常量池,class文件中的常量池不仅仅包含字符串/数字这些字面量
还包含类、方法的信息,占用class文件绝大部分空间。
这种常量池主要用于存放两大类常量:字面量和符号引用量。

  • 字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等;
  • 符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:
    • 类和接口的全限定名
    • 字段名称描述符
    • 方法名称描述符

2.运行时常量池:虚拟机会将在类加载后把各个class文件中的常量池载入到运行时常量池中,
前面的静态常量池只是一个静态文件结构,运行时常量池是方法区的一部分是一块内存区域
运行时常量池可以在运行期间将符号引用解析为直接引用,
即把那些描述符(名字)替换为能直接定位到字段、方法的引用或句柄(地址)。

3.字符串常量池:字符串常量池可以理解为运行时常量池分出来的部分。
加载时,对于Class的静态常量池,如果字符串会被装到字符串常量池中。
字符串池是 JVM 层面的技术
在 JDK 1.6 以及以前的版本中,字符串池是放在 Perm 区(Permanent Generation,永久代)。
在 JDK 1.7 的版本中,字符串常量池移到Java Heap
在 JDK 1.8 的版本中在永久代移除后,字符串常量池和运行时常量池也没有放到新的方法区—元空间里,而是留在了Java堆里。元空间里只存储类和类加载器的元数据信息了!

4.还有一类常量池:实质叫包装类的对象池(也有称常量池),但时他们和JVM的静态/运行时常量池没有任何关系。
比如:我们应该听说过整型常量池,这类包装类的对象池也是池化技术的应用,
但,并非是虚拟机层面的东西,而是 Java 在类封装里实现的。

  • IntegerCache 用于缓存Integer对象
  • ByteCache用于缓存Byte对象
  • ShortCache用于缓存Short对象
  • LongCache用于缓存Long对象
  • CharacterCache用于缓存Character对象

Byte, Short, Long有固定范围: -128 到 127。对于Character, 范围是 0 到 127。除了Integer以外,这个范围都不能改变。

Java虛拟机对于Class文件每一部分的格式都有严格规定,
如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,
《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。

运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,
当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

String类的intern()方法的理解

先看一段测试:

String str1 = new String("1")+ new String("1");      
System.out.println(str1.intern() == str1);   
System.out.println(str1 ==  "11");  

JDK版本1.8,输出结果为:

true
true

再将上面的例子加上一行代码:

String str2 = "11";//新加的一行代码,其余不变  
String str1 = new String("1")+ new String("1");      
System.out.println(str1.intern() == str1);   
System.out.println(str1 ==  "11");  

再运行,结果为:

false
false

是不是感觉莫名其妙!
新定义的str2好像和str1没有半毛钱的关系,怎么会影响到有关str1的输出结果呢?
intern方法的作用是把字符串加载到常量池中,返回在常量池里的对象的引用。

第一种情况:

String str1 = new String(“1”)+ new String(“1”);
这行代码在字符串常量池中生成“1”,
并在堆空间中生成str1 引用指向的对象(内容为"11")。
注意此时常量池中是没有 “11”对象的。

str1.intern()
这一行代码,是将 str1中的“11”字符串放入 String 常量池中,此时常量池中不存在“11”字符串,
JDK8的常量池中不会再重复新创建一份对象了,直接存储堆中的引用就是str1,常量池中放的是引用。
System.out.println(str1.intern() == str1); 返回true。

str1 == “11” ;
这一行代码会直接去常量池中创建 “11” ,但是发现已经有这个对象了,还是维持原来的引用。
System.out.println(str1 == “11”); 返回true。

第二种情况:

str2先在常量池中创建了“11”,常量池中放的是“11”这个字符串对象本身。
那么str1.intern(),想将 str1中的“11”字符串放入 String 常量池中,但是发现已经有这个对象了,
常量池中不需要再重复创建一份对象了,当然就直接指向了str2,

System.out.println(str1.intern() == str1); 返回false。

str1 == “11” ;
这一行代码会直接去常量池中创建 “11” ,但是发现已经有这个对象了,还是维持原来的引用str2。
System.out.println(str1 == “11”); 返回false

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
掌握Ubuntu,您可以采取以下步骤: 1. 下载Ubuntu镜像:您可以从官方网站下载Ubuntu的最新版本。根据您的硬件架构选择合适的版本。 2. 创建启动U盘:使用免费的win32diskimager工具将下载的Ubuntu镜像烧录到U盘上。这样您就可以通过U盘启动安装Ubuntu。将U盘插入计算机,并按照工具的指示进行操作。 3. 安装Ubuntu:将启动U盘插入需要安装Ubuntu的计算机,重新启动计算机。在启动时,选择从U盘启动。按照屏幕上的提示进行Ubuntu的安装过程。您可以选择安装到硬盘上,或者选择试用Ubuntu而不进行实际安装。 4. 更新系统:在安装完成后,建议您更新系统以获取最新的补丁和软件包。打开终端并运行以下命令:sudo apt update && sudo apt upgrade 5. 安装必要的软件:根据您的需求,可以安装各种软件。例如,如果您需要进行深度学习开发,可以安装CUDA和PaddlePaddle。 6. 学习命令行操作:Ubuntu是一个基于Linux的操作系统,使用命令行是非常常见的。您可以学习一些基本的Linux命令,例如文件和目录操作、软件包管理等。 7. 探索图形界面:Ubuntu提供了直观的图形界面,您可以通过点击图标和菜单来执行各种操作。尝试打开不同的应用程序,了解它们的功能和用法。 通过以上步骤,您将能够快速上手并掌握Ubuntu操作系统。记得多练习和实践,以加深对Ubuntu的理解和熟练度。祝您成功!

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值