「周一电台 x 训练营」从三道题开始,认识Java内存

我正在参与掘金创作者训练营第4期,点击了解活动详情,一起学习吧!

0. 阅读完本文你将学会

  • 解决三道Java内存面试题
  • Java内存区域的分配、特点

1. 三道题

  1. 介绍下Java内存区域。
  2. 以下正确的打印结果应该是?
    String str1 = "str";
    String str2 = "ing";
    String str3 = "str" + "ing";
    String str4 = str1 + str2;
    String str5 = "string";
    String str6 = new String("string");
    System.out.println(str3 == str4);
    System.out.println(str3 == str5);
    System.out.println(str4 == str5);
    System.out.println(str5 == str6);
复制代码
  1. Java内存中堆内存和栈内存的区别?

带着这三个问题一起来看看Java内存吧,文末有答案哦。

2. 运行时数据区

本文针对的是Java默认的HotSpot虚拟机

除了直接内存,Java的运行时数据区一般分为下面两类:

  • 线程共享
  1. 方法区
  • 线程私有
  1. 虚拟机栈
  2. 本地方法栈
  3. 程序计数器

2.1 堆(Heap)

2.1.1 堆的定义

当笔者第一次接触Java内存中的堆的时候,不禁好奇,为什么叫这个名字?

后来参考了stackoverflow之后,感觉这个名字还是比较贴切的。

A memory heap is called a heap in the same way you would refer to a laundry basket as a "heap of clothes". This name is used to indicate a somewhat messy place where memory can be allocated and deallocated at will.

这段话的大概意思是讲:

内存堆之所以被称为堆,就像你把一个洗衣篮称为一堆衣服一样。这个名字是用来表示一个有点混乱的地方。在那里内存可以被随意分配和删除。

并且内存中的堆是区别于数据结构中的堆的。

我们现在讲的是堆是JVM管理的内存中最大的一块,它在虚拟机启动的时候创建。

几乎所有的对象实例以及数组在堆里分配内存。Java堆还是垃圾收集器(Garbage Collection,GC)管理的主要区域,因此我们也可以叫它GC堆,请勿叫做垃圾堆

2.1.2 堆的划分

从GC的角度来看,现在的收集器基本采用分代垃圾收集算法,所以堆可以细分为:

JDK8之前

  • 年轻代(Young Generation )
  • 老年代(Old or Tenured Generation)
  • 永久代(Permanent Generation )

其中,年轻代又可以细分为Eden区、From Survivor区(亦称为S0)、To Survivor区(亦称为S1)。

JDK8之后

永久代也就是下文中的方法区被移除了,取而代之的是存在于本地内存中的元空间(Metaspace)

  1. 年轻代

图中的Eden区和两个Survivor区都属于年轻代。

年轻代的Eden区内存是连续的,所以分配非常快。

Eden区的回收也非常快,因为大部分情况下Eden区对象存活的时间非常短,而Eden区采用了在存活对象比例很少的情况下非常高效的复制回收算法。

Eden区和Suivivor区的比例是8:1:1。至于为什么是这个比例,IBM论文中研究表明,95%的对象存活时间极短,为了保险起见默认了使用90%。每次GC都会将Eden区和From Survivo区的存活对象复制到To Survivor区中。

对象在被创建时,先被分配在年轻代中(大对象可以直接在老年代中分配)。当年轻代需要回收时会触发Minor GC(亦称为Young GC)。

  1. 老年代

顾名思义,老年代是用来存放一些老一点的对象——在年轻代中多次GC之后依然存活的对象,例如缓存对象。

新建的对象也有可能在老年代上直接分配内存:

  • 一种为大对象。可以通过启动参数设置-XX:PretenureSizeThreshold=1024,表示超过多大时就不在年轻代分配,而是直接在老年代分配。
  • 另一种为大的数组对象,且数组对象中无引用外部对象。

当老年代满了的时候就需要对老年代进行GC,老年代的垃圾回收称作Major GC(亦称为Full GC)。

2.1.3 堆的报错

一般在堆中最容易出现的就是OutOfMemoryError,比如:

  1. OutOfMemoryError: Java heap space

JVM创建的对象太多了,在进行GC之前,虚拟机分配到堆的空间已经用满。

  1. OutOfMemoryError: GC Overhead Limit Exceeded

这个错误是由于JVM花费太长时间执行GC且只能回收很少的堆内存时抛出的。

根据Oracle官方文档,默认情况下,如果Java进程花费98%以上的时间执行GC,并且每次只有不到2%的堆被恢复,则JVM抛出此错误。

2.1.4 运行时常量池(Runtime Constant Pool)

JDK8开始,运行时常量池被从方法区中移出来,放进了堆中。

运行时常量池是为了避免频繁创建和销毁对象而影响系统性能,实现了对象的共享。比如字符串常量池,在编译阶段就把所有的字符串放进一个常量池中。

运行时常量池通常包括以下内容:

  1. 字面量
  • 文本字符串
  • final常量
  • 基本数据类型
  1. 符号引用
  • 类和结构的完全限定名
  • 字段名称和描述符
  • 方法名称和描述符

2.2 方法区(Method Area)

方法区用于存储已被虚拟机加载的类信息、常量、静态变量、动态生成的类等数据。

实际上在Java虚拟机的规范中,方法区是堆中的一个逻辑部分,但是它却拥有一个叫做非堆(Non-Heap)的别名。

方法区和永久代的关系

方法区是JVM规范中的定义,永久代是HotSpot的概念,永久代是一种方法区的实现。

2.3 虚拟机栈(VM Stack)

2.3.1 栈的机制

Java内存可以粗糙地区分为堆内存和栈内存。其中栈内存就是本节中的虚拟机栈,或者说是虚拟机栈中局部变量部分。

局部变量表主要存放了基本数据类型以及对象引用。

虚拟机栈是线程私有的,每个线程都有各自的栈。栈随着线程的创建而创建,随着线程的死亡而死亡。

Java栈可用类比数据结构中的栈,Java栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入Java栈,每一个函数调用结束后,都会有一个栈帧被弹出。

Java方法有两种返回方式:

  • return语句
  • 抛出异常

这两种返回都会导致栈帧被弹出。

2.3.2 栈的报错

  1. StackOverFlowError

每一个 JVM 线程都拥有一个私有的 JVM 线程栈,用于存放当前线程的 JVM 栈帧(包括被调用函数的参数、局部变量和返回地址等)。

如果某个线程的线程栈空间被耗尽,没有足够资源分配给新创建的栈帧,就会抛出java.lang.StackOverflowError错误。

最常见的原因便是循环调用,一个典型例子:

    public class StackOverFlowErrorDemo {

        public static void Foo(){
            Foo();
        }

        public static void main(String[] args) {
            Foo();
        }
    
    }
复制代码

2.4 本地方法栈(Native Method Stack)

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。

2.5 程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。

字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储。

程序计数器占用内存很小,在进行JVM内存计算时,可以忽略不计。

它是唯一一个在JVM规范中没有规定任何OutOfMemoryError的区域。

3. 习题解答

首先,我们通过一个实际的例子来巩固对上文内存的理解。

请看下面这段程序:

    class Person {
        int id;
        String name;

        public Person(int id, String name) {
            this.id = id;
            this.name = name;
        }
    }

    public class PersonBuilder {
        private static Person buildPerson(int id, String name) {
            return new Person(id, name);
        }

        public static void main(String[] args) {
            int id = 23;
            String name = "John";
            Person person = null;
            person = buildPerson(id, name);
        }
    }
复制代码
  1. 当我们进入main()方法,栈内存开辟一块空间来存储基本类型和这个方法的引用:
  • id直接存储在了栈内存
  • person对象的引用也存在了栈内存
  1. Person(int, String)构造函数的的调用将在先前的堆栈之上进一步分配内存,这将会存储:
  • this对象的引用
  • id
  • name字符串的引用,它指向堆内存中的常量池的字符串
  1. main()方法进一步调用buildPerson()静态方法,为此将在先前的基础上在堆栈内存中进行进一步分配。

  2. 堆内存将为新创建的Person对象存储所有实例变量。

下面的图可以帮我们更好地理解程序运行时内存的分配:

然后再来看看开头的三道题。

  1. 介绍下Java内存区域。

JDK8之前,可以分为堆、方法区、虚拟机栈、本地方法栈、程序计数器。 JDK8之后,方法区被移除,运行时常量池也移到了堆中,其他的区域未变。

  1. 以下正确的打印结果应该是?
    String str1 = "str";
    String str2 = "ing";
    String str3 = "str" + "ing";
    String str4 = str1 + str2;
    String str5 = "string";
    String str6 = new String("string");
    System.out.println(str3 == str4);//false
    System.out.println(str3 == str5);//true
    System.out.println(str4 == str5);//false
    System.out.println(str5 == str6);//false
复制代码

str4因为是字符串拼接出来的,相当于str6的new方法,都是重新创建了对象。

而直接使用双引号声明出来的String对象会直接存储在常量池中。所以str3、str5是指向的常量池中的同一个字符串。

  1. Java内存中堆内存和栈内存的区别?
存储 由new创建的对象和数组 基本类型的变量和对象的引用
生命周期 跟随应用 跟随线程
执行速度
内存分配/释放 新对象被创建时,内存被分配,当它们不再被引用时,内存会被释放。 当一个方法被调用和返回时,内存自动分配和释放

往期精选

  1. Java8-15的新特性,你知道几个?

  2. 从开源创业之星到造炸弹,最后删库跑路,他经历了什么?

  3. 11个值得掌握的Java代码性能优化技巧

  4. 阿里巴巴的Java开发手册(黄山版)来了

感谢收看本期的翊君@周一电台。如果你觉得还不错的话,快给我三连支持一下吧,咱们下期不见不散呐。

Guess you like

Origin juejin.im/post/7069505087709642788