0%

JVM学习笔记

前言

这是个人在学习JVM时记录的学习笔记,大体上分为内存与垃圾回收以及性能监控与调优两部分

一、内存与垃圾回收篇

1. 类加载子系统

1.1 类加载器与类加载子系统概述

类加载器子系统作用:负责从文件系统或网络中加载.class文件生成Class文件,.class文件在文件开头有特定的文件标识;(类加载器ClassLoader只负责.class文件的加载,至于它是否可以运行,则由Execution Engine决定)

注意:加载的类信息(DNA元数据模板)存放在方法区中,除了类信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

1.2 类的加载过程(类的生命周期)

类的加载过程分为三部分:Loading加载→Linking链接→Initialization初始化(其中链接阶段包括Verification验证→Preparation准备→Resolution解析)

类的加载过程一:Loading加载
  • 加载的理解:

    将Java类的字节码文件加载到机器内存中,并在内存中构建出类模板对象

  • 加载完成的操作:

    • 通过一个类的全限定名(即全类名)获取定义此类的二进制字节流
    • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    • 创建java.lang.Class类的实例,作为方法区这个类的各种数据的访问入口
类的加载过程二:Linking链接
  • 验证
    • 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证加载的字节码是合法、合理并符合规范的
    • 主要包括四种验证方式:文件格式验证、元数据验证、字节码验证、符号引用验证
  • 准备
    • 为类变量分配内存并且设置该类变量的默认初始值
    • 注意:不包括用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;也不会为实例变量分配初始化,因为类变量会分配在方法区中,而实例变量会随着对象一起分配到堆中;
  • 解析
    • 将常量池内的符号引用转换为直接引用的过程
    • 解析操作会随着JVM在执行完初始化之后再执行
    • 解析主要针对类或接口、字段、类方法、接口方法、方法类型等
类的加载过程三:Initialization初始化
  • 初始化阶段就是执行类构造器方法<clinit>()的过程,注意<clinit>()不等同于类的构造器
  • 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁

1.3 类加载器分类

JVM支持两种类型的类加载器,分别为引导类加载器Bootstrap ClassLoader和自定义类加载器User-Defined ClassLoader(注意所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器)

常见的3个类加载器:

  1. 启动类加载器(引导类加载器Bootstrap ClassLoader)
    • 嵌套在JVM内部,这个加载器用来加载Java核心库(JAVA_HOME、jre、lib、rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
    • 并不继承自ClassLoader抽象类,没有父加载器
    • 加载扩展类或应用程序类加载器,并指定为他们的父类加载器
    • 出于安全考虑,启动类加载器只加载包名为java、javax、sun等开头的类
  2. 扩展类加载器(Extension ClassLoader)
    • 派生于ClassLoader抽象类、父类加载器为启动类加载器
    • 从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库,如果用户创建的JAR放在此目录下,也会自动有扩展类加载器加载
  3. 应用程序类加载器(系统类加载器,AppClassLoader)
    • 派生于ClassLoader抽象类、父类加载器为启动类加载器
    • 负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
    • 是程序中默认的类加载器,一般Java应用的类都是由它来完成加载

1.4 双亲委派机制

工作原理:

  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  2. 如果父类加载器还存在其父类加载器,则进一步向上委托依次递归,请求最终将到达顶层的启动类加载器;
  3. 如果父类加载器可以完成类加载任务就成功返回,若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派机制。

双亲委派机制的优势:

  • 避免类的重复加载
  • 保护程序安全,防止核心API被随意篡改

沙箱安全机制:

含义:就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问。通过这样的措施来保证对代码的有限隔离,防止对本地系统造成破坏。

2. 运行时数据区

2.1 运行时数据区概述

JVM虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动和退出而创建和销毁;另外一些则与线程一一对应,随着线程开始和结束而创建和销毁

  • 每个线程私有:程序计数器、虚拟机栈、本地方法栈;(生命周期与线程生命周期保持一致)
  • 线程间共享:堆、方法区(永久代或元空间、代码缓存);(生命周期与虚拟机的生命周期保持一致)

2.2 程序计数器(PC寄存器)

2.2.1 程序计数器概述
  • JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟;
  • 唯一一个在虚拟机规范中没有规定任何OutOtMemoryError情况的区域;

作用:用来存储指向下一条指令的地址,即是将要执行的代码。由执行引擎读取下一条指令。

2.2.2 程序计数器常见问题
  1. 使用PC寄存器存储字节码指令地址有什么用呢?

    答:因为CPU需要不停地切换各个进程,有可能导致切换回来以后就不知道接着从哪开始继续执行,因此JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令;

  2. PC寄存器为什么会被设定为线程私有?

    答:每个线程都分配一个PC寄存器目的在于使能够准确记录各个线程正在执行的当前字节码指令地址,这样每个线程之间就可以进行独立计算不会出现相互干扰的情况;

2.3 虚拟机栈(重点)

2.3.1 虚拟机栈概述

内存中的栈和堆:

栈是运行时的单位,而堆是存储的单位,即栈解决程序的运行问题(程序如何执行或者如何处理数据),而堆解决数据的存储问题(数据怎么放,放在哪儿)。

Java虚拟机栈概念:

每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应着一次次的Java方法调用

虚拟机栈作用:

管理Java程序的运行,保存方法的局部变量、部分结果,并参与方法的调用的返回

虚拟机栈的特点:

  • JVM直接对虚拟机栈的操作只有两个:
    • 每个方法执行伴随着对栈帧的压栈
    • 方法执行结束后对应栈帧就执行出栈
  • 对于栈来说,其访问速度仅次于程序计数器。并且其不存在垃圾回收问题
2.3.2 栈中可能出现的异常
  1. 如果采用固定大小的虚拟机栈,那每一个线程的虚拟机栈容量可以在线程创建的时候独立选定,如果线程请求分配的栈容量超过虚拟机栈允许的最大容量,将会抛出StackOverflowError(栈溢出)异常。
  2. 如果虚拟机栈可以动态扩展,并且在尝试扩展时无法申请到足够的内存,或者在创建新的线程时没有足够内存去创建对应的虚拟机栈,将会抛出OutOfMemoryError(内存溢出)异常
2.3.3 栈的存储结构和运行原理

栈的存储结构:

每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在,在线程上正在执行的每个方法都各自对应一个栈帧(栈帧是一个内存区块,维系着方法执行过程中的各种数据信息);

栈的运行原理:

  • 在一个活动线程中,每个时间点上只会有一个活动的栈帧,即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧,与当前栈帧相对应的方法就是当前方法,定义这个方法的类就是当前类

  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作;

  • 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在站的顶端成为新的当前帧;

  • 不可以在一个栈帧之中引用另外一个线程的栈帧;(栈是每个线程私有的)

  • 如果当前方法调用了其他方法,在方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧;

2.3.4 栈帧的内部结构

每个栈帧中都存储着局部变量表操作数栈(表达式栈)、动态链接(指向运行时常量池的方法引用)、方法返回地址(方法正常退出或异常退出的定义)、一些附加信息

  • 局部变量表

    • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量
    • 局部变量表所需的容量大小是在编译期确定下来的,方法运行期间是不会改变局部变量表的大小的;
    • 局部变量表中的变量只在当前方法调用中有效,当方法调用结束后随着方法栈桢的销毁,局部变量表也会随之销毁;
    • 局部变量表最基本的存储单元是Slot(变量槽),32位以内的类型只占用一个slot,64位的类型(long和double)占用两个slot;
    • 局部变量表中的每一个Slot都分配一个访问索引用于访问,如果需要访问局部变量表中一个64位的局部变量值时,只需要使用前一个索引即可;
  • 操作数栈

    • 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
    • 当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
    • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好,保存在方法的Code属性中,为max_stack的值
    • 32位类型数据占用一个栈单位深度,64位类型数据占用两个栈单位深度
    • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
    • 如果被调用的方法带有返回值的话,其返回值将会被压入当前站真的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令

    栈顶缓存技术:将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率

  • 动态链接

    • 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接;

      • 为什么需要常量池呢?

        常量池的作用就是为了提供一些符号和常量,便于指令的识别

    • 作用是为了将这些符号引用转换为调用方法的直接引用

  • 方法返回地址

    • 作用是用于存放该方法的PC寄存器的值
    • 当方法正常退出时,调用者的PC寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址;当异常退出时,不会给上层调用者产生任何返回值
2.3.5 方法的调用
  1. 方法的绑定机制
    • 静态链接:被调用的目标方法在编译期可知且运行期间保持不变,这种情况下将调用方法的符号引用转换为直接引用的过程(早期绑定)
    • 动态链接:被调用的目标方法在编译期无法被确定下来,这种情况下只能在程序运行期将调用方法的符号引用转换为直接引用的过程,具有动态性(晚期绑定)
  2. 虚方法与非虚方法
    • 非虚方法:方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法成为非虚方法(非虚方法包含静态方法、私有方法、final方法、实例构造器、父类方法)
    • 虚方法:除了以上非虚方法的都是虚方法
2.3.6 虚拟机栈常见问题
  1. 举例栈溢出(StackOverflowFrror)的情况

  2. 调整栈大小就能保证不出现溢出吗?

    :不能,调整栈大小只能是延迟栈溢出的情况,但是不能保证一定不出现栈溢出

  3. 垃圾回收是否会涉及到虚拟机栈?

    :不涉及,因为虚拟机栈是每个线程私有的;

  4. 方法中定义的局部变量是否线程安全?

    :如果只有一个线程操作此数据,则必定是线程安全的;如果多个线程操作此数据,则此数据是共享数据,如果不考虑同步机制的话,会存在线程安全问题

2.4 本地方法栈

2.4.1 本地方法栈概述
  • 虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用
  • 本地方法栈允许被实现成固定或者可动态扩展的内存大小(在内存溢出和栈溢出方面与虚拟机栈相同)
  • 本地方法使用C语言实现,在本地方法栈中登记本地方法,在执行引擎执行时加载本地方法库
  • 当某个线程调用一个本地方法时,本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存
  • HotSpot JVM将虚拟机栈和本地方法栈合成了一块区域

2.5 堆空间(重点)

2.5.1 堆的核心概述
  • 一个JVM实例只存在一个堆内存,堆是Java内存管理的核心区域,所有的对象实例以及数组在运行时都分配在堆上
  • 堆内存的大小是可以调节的,其在JVM启动时就被创建,其空间大小也就确定了(JVM管理最大的内存空间)
  • 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
  • 所有的线程共享Java堆,在堆中还可以划分线程私有的缓冲区(TLAB)
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾回收的时候才会被移除
  • 堆空间内存可细分为:年轻代(新生区)+老年代(养老区)+元空间
2.5.2 设置堆内存大小
  • -Xms用于表示堆区的起始内存,等价于-XX:InitialHeapSize
  • -Xmx用于表示堆区的最大内存,等价于-XX:MaxHeapSize,一旦堆区内存大小超过指定的最大内存时,将会抛出OutOfMemory异常
  • 通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小从而提高性能
2.5.3 年轻代与老年代
  • 堆区进一步细分可以划分为年轻代和老年代,年轻代又可划分为Eden空间、两块大小相同的Survivor(又称为from区和to区,to区总为空),老年代存放新生代中经历多次GC仍然存活的对象
  • 可以通过-XX:SurvivorRatio调正Eden空间和另外两个SAurvivor空间的空间比例
  • 几乎所有的Java对象都是在Eden区被new出来的,绝大部分的Java对象的销毁都在新生代进行
2.5.4 对象分配过程
  1. new的对象先放到Eden区,当Eden区的空间填满而程序又需要创建对象时,JVM的Minor GC将对Eden区进行垃圾回收,将Eden去中不再被其他对象所引用的对象销毁,再加载新的对象放到Eden区,然后将Eden区中的剩余对象移动到from区
  2. 如果再次触发垃圾回收,此时上次幸存下来放到from区的对象,如果没有回收就会放到to区
  3. 如果再次经历垃圾回收,此时会重新回到from区,接下来再去to区,不断循环此过程直到计数器到达设置值,如果还没有被回收就从年轻代移动到老年代
2.5.5 对于堆的GC垃圾回收概述

概述:频繁在新生代收集,很少在老年代收集,几乎不在元空间收集

  • 部分收集:
    • Minor GC:只是新生代的垃圾收集
      • 当新生代空间不足(指的是Eden区满,Survivor区满互惠引发GC)就会触发Minor GC
      • Minor GC工作频繁且回收速度较快,工作时会引发STW,暂停其他用户的线程,等垃圾回收结束后用户线程才恢复运行
    • Major GC:只是老年代的垃圾收集
      • 目前只有CMS GC会有单独收集老年代的行为
      • 老年代空间不足时会先尝试触发Minor GC,如果之后空间还不足则触发Major GC,如果Major之后空间还不足就报OOM异常了
      • Major GC的速度一般比Minor GC慢10倍以上,STW的时间更长
    • Mixed GC:收集整个新生代以及部分老年代的垃圾收集
  • 整堆收集:
    • Full GC:收集整个堆和方法区的垃圾收集
2.5.6 堆空间分代思想
  • 分代的原因是优化GC性能,如果没有分代那么GC时就需要对堆的所有区域进行扫描才能找到哪些对象没用,很多对象的生命周期很短,而通过分代就可以在GC是先把这部分对象的区域进行回收从而腾出很大的空间
2.5.7 内存分配策略

针对不同年龄段的对象分配原则如下:

  • 优先分配到新生代的Eden区
  • 大对象直接分配到老年代,长期存活的对象也分配到老年代
  • 如果Survivor区中相同年龄的所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到要求的年龄(动态对象年龄判断)
2.5.8 为对象分配缓冲区内存:TLAB
  • TLAB概念:对Eden区继续进行划分,在Eden区中JVM为每个线程分配一个私有缓存区域,可以避免一些多线程环境下的线程安全问题
  • 默认情况下,TLAB空间内存非常小,仅占整个Eden空间的1%
  • 一旦对象在TLAB空间分配内存失败时,JVM会通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存
2.5.9 堆是分配对象存储的唯一选择吗?
  • 如果经过逃逸分析后发现,一个对象如果没有逃逸出方法的话,那么就可能被优化成在栈上分配
    • 逃逸分析概述:一种分析算法,基本行为就是分析对象动态作用域
      • 当一个对象在方法中被定义时,对象只在方法内部使用,则认为没有发生逃逸(没有发生逃逸的对象则可以分配到栈上,随着方法执行的结束占空间就被移除)
      • 当一个方法在方法中被定义后,他被外部方法所引用,则认为发生逃逸,例如作为调用参数传递到其他地方中
    • 代码优化:开发中能使用局部变量的,就不要使用在方法外定义的变量
      1. 栈上分配:经过逃逸分析如果发现一个对象并没有逃逸出方法,就可以将堆分配转化为栈分配
      2. 同步省略(锁消除):如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
      3. 分离对象或标量替换:如果对象不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分或全部可以不存储在内存,而是存储在CPU寄存器中

2.6 方法区 / 元空间(重点)

2.6.1 栈、堆和方法区的交互关系

以一行代码为例:

1
Person person = new Person();

person方法的局部变量,存放在Java虚拟机栈的本地变量表中,指向在堆空间中new Person()创建的对象实例数据,在对象实例数据中有到对象数据类型的指针,指向在方法区中的对象类型数据Person

2.6.2 方法区的理解
  • 方法区可以看作是一块独立于Java堆的内存空间,与堆一样是各个线程共享的内存区域
  • 方法去的大小决定了系统可以保存多少个类,如果定义了太多的类导致方法区溢出,同样会抛出内存溢出错误OOM(例如加载大量的第三方jar包、Tomcat部署的工程过多,大量的动态生成反射类)
  • 元空间与JDK 7以前的永久代都是对JVM规范中方法去的实现,不过元空间与永久代区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
2.6.3 设置方法区大小
  • 元空间大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定
  • 如果不指定大小,默认情况下虚拟机会耗尽所有的可用系统内容,如果元数据区发生溢出虚拟机会抛出OOM异常
2.6.4 方法区的内部结构
  • 方法区作用:用于存储已被虚拟机加载的类型信息(包括域信息、方法信息)、常量(存放在运行时常量池中)、即时编译器编译后的JIT代码缓存等

  • 运行时常量池

    • 常量池表是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
    • 在加载类和接口到虚拟机后,就会创建对应的运行时常量池
    • JVM为每个已加载的类型都维护一个常量池,池中的数据项通过索引访问
  • JDK 7后字符串常量池静态变量从方法区放到了堆空间中

    • 为什么要调整字符串常量池位置呢?

      因为JDK 7 以前方法区是永久代,而永久代回收效率很低,只在Full GC时才触发回收,Ful GC只在堆的老年代空间不足,或永久代不足十才会触发,导致字符串常量池回收效率不高,而开发会创建大量字符串容易导致永久代内存不足,放在堆里回收效率能提高

2.6.5 对于方法区的GC垃圾回收概述

方法区垃圾回收主要回收两部分内容:常量池中废弃的常量不再使用的类型

2.7 对象的实例化、布局与访问定位

2.7.1 对象的实例化
  • 创建对象的方式:使用new调用构造器、反射方式(Class的newInstance()、Constructor的newInstance(xxx))、使用clone()、使用反序列化;
  • 创建对象的步骤:
    1. 判断对象对应的类是否加载、链接、初始化
    2. 为对象分配内存,如果内存规整指针碰撞,如果内存不规整虚拟机需要维护一个列表,空闲列表分配
    3. 处理并发安全问题,采用CAS失败重试,区域加锁保证更新的原子性,并且每个线程预先分配一块TLAB
    4. 初始化分配到的空间,所有属性设置默认初始值,保证对象实例字段再不复置时可以直接使用
    5. 设置对象的对象头
    6. 执行init方法进行初始化
2.7.2 对象访问定位

对象访问方式有两种:

  1. 句柄访问(将到对象实例数据的指针保存在句柄池,指针指向栈中实例池中的对应对象实例数据)
  2. 直接指针(如2.6.1方式所示,Hotspot采用)

3 本地方法接口与本地库

  • 一个本地方法(Native Method)就是一个Java调用非Java代码的接口,本地方法接口的作用是为了融合不同的编程语言为Java所用,在定义一个Native Method时并不提供实现体,其实现体由非Java语言在外面实现
  • 使用标识符native定义本地方法,可以与除了abstract之外的所有java标识符连用

4. 执行引擎

4.1 执行引擎概述

4.1.1 执行引擎作用

将字节码指令解释编译为对应平台上的本地机器指令(即将高级语言翻译为机器语言

4.1.2 执行引擎工作过程

输入字节码二进制流,处理过程是字节码解析执行的等效过程,输出执行结果,每当执行完一项指令后PC寄存器就会更新下一条需要被执行的指令地址

4.2 Java代码编译和执行过程

程序源码→词法分析→单词流→语法分析→抽象语法树→(解释过程)指令流→解释器→解释执行

​ (编译过程)优化器→中间代码→编译生成器→目标代码

4.3 机器码、指令、汇编语言

机器指令码(机器语言):用二进制编码方式表示的指令

指令:把机器码中特定的0和1序列简化为相应的命令

汇编语言:用助记符代替机器指令的操作码,用地址符号或标号代替指令或操作数的地址

4.4 解释器

4.4.1 解释器作用

根据预定义的规范对字节码采用逐行解释的方式执行,将字节码文件中的内容翻译为对应平台的本地机器指令执行

4.5 JIT编译器

4.5.1 JIT编译器作用

将源代码直接编译成和本地机器平台相关的机器语言

4.5.2 HotSpot VM中JIT分类

HotSpot VM有两个JIT编译器,分别为Client Compiler和Server Compiler,简称为C1编译器和C2编译器,C1会对字节码进行简单和可靠的优化,耗时短能达到更快的编译速度,C2进行耗时较长的优化以及激进优化,但优化的代码执行效率更高

5. String Table

5.1 String基本概述

  • 字符串常量池中不会存储相同内容的字符串
  • String的字符串常量池是一个固定大小的Hashtable(数组+链表)

5.2 String的内存分配

  • 直接使用双引号声明的String对象会直接存储在常量池中,如果不是双引号声明的String对象,可以使用String提供的intern()方法
  • JDK 7以前字符串常量池存放在永久代,JDK 7后字符串常量池的位置调整到Java堆空间中,可以是在进行调优时仅需调整堆大小就可以

5.3 字符串拼接操作

  • 常量与常量,或者两者都是常量引用的拼接结果在常量池,原理是编译器优化,常量池中不存在相同内容的常量(注意使用final修饰的变量也算是常量)
  • 只要其中有一个是变量,拼接结果就直接在堆中而不在常量池,原理是StringBuilder
  • 如果拼接对象的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中并返回此对象地址
  • 通过StringBuilder的append()方式添加字符串的效率要远高于使用String的字符串拼接方式,StringBuilder的append()方式自始至终只创建过一个StringBuilder对象,在实际开发中建议使用构造器new StringBuilder(highLevel)指定数组长度优化执行效果

5.4 intern()的使用(重点)

  • 作用:判断字符串常量池中是否存在该字符串,如果存在则返回常量池中该字符串地址,如果不存在则将此字符串放入常量池中并返回此对象的地址

  • 如果在任意字符串上调用String.intern()方法,那么其返回结果所指向的那个类实例必须和直接以常量形式出现的字符串实例完全相同

    1
    ("a"+"b"+"c").intern() == "abc"

拓展题目

  1. new String(“ab”)会创建几个对象?

答:创建了两个对象,一个对象是new关键字在堆空间创建的,另一个对象是字符串常量池中的对象

  1. new String(“a”) + new String(“b”)又会创建几个对象?

答:创建了六个对象,对象1是new StringBuilder(),对象2是new String(“a”),对象3是字符串常量池中的“a”,对象4是new String(“b”),对象5是字符串常量池中的“b”,对象6是StringBuilder.toString()的new String(“ab”)(注意toString()的调用,在字符串常量池中没有生成“ab”)

  • String的intern()使用总结:
    • JDK 6时将字符串对象尝试放入字符串常量池,如果字符串常量池有则不会放入,返回已有的字符串常量池中的对象的地址;如果没有,则把此对象复制一份放入字符串常量池并返回字符串常量池中的对象地址
    • JDK 7以后将字符串对象尝试放入字符串常量池,如果字符串常量池有则不会放入,返回已有的字符串常量池中的对象的地址;如果没有,则把对象的引用地址复制一份放入字符串常量池并返回字符串常量池中的引用地址

6. 垃圾回收

6.1 垃圾回收概述

  • 什么是垃圾?

    垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾

  • 为什么需要GC?

    如果不进行垃圾,内存迟早都会被消耗完,而垃圾回收除了可以释放没用的对象,还可以清除内存中的记录碎片,以便JVM将清理出的内存分配给新的对象,可以说GC保证了应用程序的正常进行

6.2 垃圾回收相关算法(重点)

6.2.1 引用计数算法(用于标记阶段,Java没有采用)

垃圾标记阶段:在GC执行垃圾回收之前,首先需要区分内存中哪些是存活对象,哪些是已经死亡的对象,只有被标记为死亡的对象(其不再被任何存活对象继续引用)在执行垃圾回收是才会释放掉其所占用的内存空间

引用计数算法概念:对每个对象保存一个整型的引用计数器属性用于记录对象被引用的情况

引用计数算法的优缺点:

  • 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性;
  • 缺点
    • 需要单独的字段存储计数器,增加了存储空间开销
    • 每次赋值都需要更新计数器,增加了时间开销
    • 无法处理循环引用的情况(基于此Java的垃圾回收器没有使用这种算法)
6.2.2 可达性分析算法(用于标记阶段)

可达性分析算法概念:也叫做追踪性垃圾收集,以根对象集合(GC Roots)为起始点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达;

  • 内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径成为引用链,如果目标对象没有任何引用链相连则是不可达的,意味着该对象已经死亡,可以标记为垃圾对象
  • GC Roots包括以下几类元素:
    • 虚拟机栈中引用的对象(如各线程被调用的方法中使用到的参数、局部变量等)
    • 本地方法引用的对象
    • 方法区中类静态属性引用的对象(如Java类的引用类型静态变量)
    • 方法区中常量引用的对象(如字符串常量池里的引用)
    • 所有被同步锁synchronized持有的对象…
6.2.3 对象终止finalization机制

对象终止机制概念:在对象被销毁之前的自定义处理逻辑,在垃圾回收此对象之前总会先调用这个对象的finalize()方法

  • 有序finalize()的存在,虚拟机中的对象一般处于三种可能的状态:
    • 可触及的:从根节点开始可以到达这个对象
    • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活
    • 不可触及的:对象的finalize()已被调用,并且没有复活,那么就进入不可触及状态,只有不可触及才可以被回收
6.2.4 标记-清除(Mark-Sweep)算法(用于清除阶段)

标记清除算法执行过程:

  • 标记:收集器从引用根节点开始遍历,标记所有被引用的对象,一般是在对象头中记录为可达对象
  • 清除:收集器对堆内存从头到尾进行线性遍历,如果发现某个对象在其对象头中没有标记为可达对象时则将其回收(清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表中,当有新对象需要加载时,判断被定位垃圾的位置空间是否足够,如果够就覆盖存放)

标记清除算法缺点:

效率不算高,而且在进行GC时需要停止整个应用程序;清除出来的空闲空间是不连续的,会产生内存碎片,需要维护一个空闲列表

6.2.5 复制算法(用于清除阶段)

复制算法思路:将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存块中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色完成垃圾回收

复制算法优缺点:

  • 优点:
    • 没有标记和清除过程运行高效
    • 复制过去以后保证空间的连续性,不会出现碎片问题
  • 缺点:
    • 内存和时间花销极大,并且要使用该算法,要求需要复制的存活对象的数量不能太大,或者说要非常少才行

复制算法应用场景:用于回收年轻代中的垃圾,如两个Survivor区(from和to区)

  • 这是因为年轻代中的存活对象时间短,所以存活对象少且垃圾对象多,所以适合使用复制算法回收;
6.2.6 标记-压缩(Mark-Conpact)算法(用于清除阶段)

标记压缩算法执行过程:

  • 标记:标记清除算法一样,从根节点开始标记所有被引用对象
  • 压缩-清除:将所有的存活对象压缩到内存的一端并按顺序排放,之后清除边界外所有的空间

标记压缩算法优缺点:

  • 优点:
    • 消除了标记清除算法中内存区域分散的缺点,需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可
    • 消除了复制算法内存减半的代价
  • 缺点:
    • 效率低于复制算法
    • 移动对象的同时如果对象被其他对象引用则还需要调整引用的地址;并且移动过程中需要全程暂停用户应用程序
6.2.7 分代收集算法
  • 年轻代

    特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁

    • 基于这种情况使用复制算法回收整理速度最快,因为复制算法的效率只和当前存活对象多少有关,因此适用于年轻代回收;内存利用率不高的问题也可以通过两个Survivor的设计缓解
  • 老年代

    特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁

    • 由于存在大量存活率高的对象,不适合使用复制算法,一般使用标记清除算法,或者标记清除与标记压缩算法混合实现
    • 标记阶段、压缩阶段的开销都与存活对象的数据成正比,清除阶段的开销与所管理区域的大小成正比
6.2.8 增量收集算法

增量收集算法基本思想:

如果一次性将所有的垃圾进行处理需要造成系统长时间的停顿(即处于STW状态),那么可以让垃圾收集线程和应用程序线程交替执行,垃圾收集线程每次只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复知道垃圾收集完成

增量收集算法缺点:

虽然交替执行能减少系统的停顿时间,但线程切换和上下文转换的消耗会使得垃圾回收的总体成本上升,造成系统吞吐量下降

6.2.9 分区算法

分区算法基本思想:

将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间而不是整个堆空间,从而减少依次GC所产生的停顿(每个小区都独立使用和独立回收)

6.3 垃圾回收相关概念补充

6.3.1 内存溢出与内存泄漏
  • 内存溢出(OOM):指没有空闲内存,并且垃圾收集器也无法提供更多的内存的情况

    内存溢出产生原因:

    • Java虚拟机的对内存设置不够
    • 代码中创建了大量大对象,并且存在被引用长时间不能被垃圾收集器收集
  • 内存泄漏(Memory Leak):指对象不会再被程序使用,但是垃圾收集器又不能回收它们的情况(内存泄漏可能会导致内存溢出,但不是必然的)

    内存泄漏例子:

    1. 单例模式

      单例的生命周期和应用程序等长,所有单例程序中如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存溢出的产生

    2. 提供close()的资源未及时关闭

      数据库连接(dataSource.getConnection())、网络连接(Socket)和IO流必须手动close,否则是不能被回收的

6.3.2 STW(Stop The World)
  • 概念:指的是GC发生过程中会产生应用程序的停顿,导致整个应用程序线程都会被暂停没有任何响应
  • STW是JVM在后台自动发起和自动完成的,在用户不可见的情况下把用户正常的工作线程全部停掉
6.3.3 垃圾回收的并行与并发
  • 并行:多条垃圾回收线程同时工作,此时用户线程处于等待状态
  • 串行:先暂停用户程序,启动JVM垃圾回收线程进行垃圾回收,回收完再启动用户程序线程
  • 并发:用户线程和垃圾回收线程交替执行,垃圾回收线程在执行时不会停顿用户程序的运行
6.3.4 安全点与安全区域

安全点:指程序能够停顿下来开始GC的特定位置

安全位置:指在一段代码片段中对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的

6.3.5 引用类型
  • 强引用:不回收

    指在程序代码之中普遍存在的引用赋值,任何情况下只要强引用关系还存在,垃圾收集器就永远不会回收被引用的对象

  • 软引用:内存不足即回收

    在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收,如果这次回收后还没有足够的内存,才会抛出内存溢出异常

  • 弱引用:发现即回收

    当垃圾收集器开始工作时,无论内存空间是否足够都会回收掉被弱引用关联的对象,即只被弱引用关联的对象只能生存到下一次垃圾收集发生为止

    参考使用:WeakHashMap

  • 虚引用:对象回收跟踪

    虚引用不会对对象的生存时间构成影响,也无法通过虚引用来获得一个对象的实例;为对象设置虚引用关联的目的是能够在这个对象被收集器回收时收到一个系统通知(虚引用必须和引用队列一起使用)

6.4 垃圾收集器

6.4.1 GC性能指标
  • 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间=程序运行时间+内存回收时间)
  • 垃圾收集开销:垃圾收集所用时间与总运行时间的比例
  • 停顿时间:执行垃圾收集时程序的工作线程被暂停的时间
  • 收集频率:收集操作发生的频率
  • 内存占用:堆区所占的内存大小

标准:在最大吞吐量优先的情况下,降低停顿时间

6.5.2 Serial回收器:串行回收

串行回收即只会使用一个CPU或一条收集线程去完成垃圾收集工作,在进行垃圾收集时必须暂停其他所有的工作线程;

  • Serial收集器采用复制算法、串行回收和STW机制的方式执行内存回收,作为HotSpot中Client模式下的默认新生代垃圾回收器
  • Serial Old收集器面向老年代垃圾回收,同样采用串行回收和STW机制,但内存回收算法使用标记-压缩算法
  • Serial Old收集器是Client模式下默认的老年代的垃圾回收器;在Server模式下有以下两个用途:
    • 与新生代的Parallel Scavenge收集器配合使用
    • 作为老年代CMS收集器的后备垃圾收集方案
  • 在HotSpot虚拟机中,使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器
6.5.3 ParNew回收器:并行回收(很少使用)
  • ParNew收集器采用复制算法、并行回收和STW机制的方式执行内存回收,可以看作是Serial收集器的多线程版本,作为Server模式下新生代的默认垃圾回收器
  • 在单个CPU的环境下,ParNew收集器不必Serial收集器高效
6.5.4 Parallel Scavenge回收器:吞吐量优先(使用频率高)
  • Parallel Scavenge收集器采用复制算法、并行回收和STW机制的方式执行内存回收,同样用于回收新生代,但与ParNew收集器不同,其目的是达到一个可控制的吞吐量
  • 适合在后台运算而不需要太多交互的任务,常见在服务器环境中使用(批量处理、订单处理等)
  • Parallel Old收集器采用并行回收和STW机制,但内存回收算法使用标记-压缩算法,用于执行老年代垃圾回收,在JDK1.6后用于替代Serial Old收集器
6.5.5 CMS回收器:低延迟(已废弃)
  • CMS收集器采用标记清除算法、并行回收和STW机制的方式执行内存回收,其关注点是尽可能缩短垃圾收集时用户线程的停顿时间
  • CMS作为老年代的垃圾收集器,无法与Parallel Scavenge回收器配合工作,当使用CMS来收集老年代时,新生代只能选择ParNew或者Serial收集器中的一个
  • CMS在进行多次GC后才进行一次碎片整理

CMS工作原理:

CMS垃圾回收分为初始标记、并发标记、重新标记和并发清除4个主要阶段:

  1. 初始标记仅仅只是标记出GC Roots能直接关联到的对象,需要停顿用户线程,一旦标记完成后就会恢复之前被暂停的所有应用线程,此阶段速度非常快;
  2. 并发标记从GC Roots的直接关联对象开始遍历整个对象图的过程,此阶段耗时较长但不需要停顿用户线程;
  3. 重新标记修正并发标记期间,因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录
  4. 并发清除清理删除掉标记阶段判断已经死亡的对象,释放内存空间

CMS回收器优缺点:

  • 优点:并发收集、低延迟
  • 缺点:
    • 由于使用标记清除算法因此会产生内存碎片,导致无法分配大对象的情况下触发Full GC
    • 对CPU资源非常敏感,在并发阶段虽然不会导致用户停顿但是会占用一部分线程导致应用程序变慢
    • 无法处理浮动垃圾,如果在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,导致这些新产生的垃圾对象没有被及时回收
6.5.6 G1回收器:分区分代(JDK 9后默认垃圾回收器)
  • Carbage First是一个并行回收器,把堆内存分割为很多不相关的区域,有计划地避免在堆中进行全区域的垃圾回收
  • 工作原理:跟踪各个区域李的垃圾堆积的价值大小(指回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,根据允许的收集时间,优先回收价值最大即垃圾最大量的区域

G1回收器特点:

  1. 并行与并发
    • 并行性:回收期间可以有多个GC线程同时工作,此时用户线程STW
    • 并发性:可以与用户线程交替执行,不会在整个回收阶段发生完全阻塞应用程序的情况
  2. 分代收集
    • G1将堆空间分为若干个区域,这些区域包含了逻辑上的年轻代和老年代,不再要求年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量;与之前的各类回收器不同,它同时兼顾了年轻代和老年代的收集
  3. 空间整合
    • 内存的回收一区域作为基本单位,每个区域(Region)内部使用复制算法,但整体上可看作是标记压缩算法,这两种算法都可以进行碎片整理
  4. 可预测的停顿时间(软实时)
    • 能让使用者明确指定一个特定长度的时间片段内消耗在垃圾回收上的时间,每次根据允许的收集时间,优先回收价值最大的区域,保证在有限时间内获取尽可能高的收集效率

G1回收器缺点:在垃圾收集产生的内存占用、程序运行时的额外执行负担都要比其他回收器要高

G1回收器垃圾回收过程:

  • 年轻代GC
    • 当年轻代的Eden区用尽时开始年轻代回收过程,暂停所有应用程序线程执行年轻代回收,然后从Eden区移动存活对象到Survivor区,或者大对象移动到老年代区
  • 老年代并发标记过程
    • 当堆内存使用达到一定值(默认为45%)时开始老年代并发标记过程,标记完成后开始混合回收过程
  • 混合回收
    • 从老年区移动存活对象到空闲区间,这些空闲区间也成为老年代的一部分,要注意的是老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的区域即可;同时这个老年代区域是和年秦代一起被回收的
6.5.7 ZGC(The Z Garbage Collector)

ZGC定义:

基于Region内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射技术实现的,运用标记压缩算法,以低延迟为首要目标的垃圾收集器

ZGC工作过程:

ZGC的工作过程可分为4个阶段:并发标记、并发预备重分配、并发重分配、并发重映射,停顿时间几乎耗费在初始标记上因为初始标记是STW的

6.5.8 GC日志分析
  • 打开GC日志:-verbose:gc或者-XX:PrintGC
  • 输出GC详细日志:-XX:PrintGCDetails
  • 输出GC的时间戳:-XX:PrintGCTimeStamps(以基准时间形式)或者-XX:PrintGCDateStamps(以日期形式)
  • 在进行GC的前后打印出堆的信息:-XX:PrintHeapAtGC
  • 日志文件的输出路径:-Xloggc:../logs/gc.log

二、性能监控与调优

1. JVM监控及诊断工具(命令行)

1.1 jps:查看正在运行的Java进程

  • -q:仅仅显示本地虚拟机唯一id,不显示主类的名称
  • -l:输出应用程序主类的全类名
  • -m:输出虚拟机进程启动时传递给主类main()的参数
  • -v:列出虚拟机进程启动时的JVM参数

1.2 jstat:查看JVM统计信息

类装载相关的:

  • -class:显示ClassLoader的相关信息,如类的装载、卸载数量、总空间、类装载所消耗的时间等

垃圾回收相关的:

  • -gc:显示GC相关的堆信息
  • -gccapacity:显示内容与-gc基本相同,但输出主要关注Java堆整个区域使用到的最大和最小空间
  • -gcutil:显示内容与-gc基本相同,但输出主要关注已使用空间占总空间的百分比
  • -gccause:与-gcutil功能一样,但是会额外输出导致最后一次或当前正在发生的GC产生的原因
  • -gcnew:显示新生代GC状况
  • -gcnewcapacity:显示内容与-gcnew基本相同,输出主要关注使用到的最大和最小空间
  • -gcold:显示老年代GC状况

1.3 jinfo:实时查看JVM配置参数

  • -flag 具体参数 PID:查看某个java进程的具体参数的值(PID即进程号)
  • -flags PID:查看曾经赋过值的一些参数
  • -sysprops PID:可以查看由System.getProperties()取得的参数

1.4 jmap:导出内存映像文件和内存使用情况

  • -dump:生成Java堆转储快照dump文件
  • -heap:输出整个堆空间的详细信息,包括整个堆空间的详细信息,包括GC的使用、对配置信息以及内存的使用信息等
  • -histo:输出堆中对象的统计信息,包括类、实例数量和合计容量

1.5 jhat:JDK堆分析工具

1.6 jstack:打印JVM中线程快照

1.7 jcmd:可用来实现前面除了jstat之外所有命令的功能

1.8 jstatd:远程主机信息收集

2. JVM运行时参数

2.1 JVM参数选项类型

2.1.1 类型一:标准参数选项

特点:以-开头

2.1.2 类型二:-X参数选项

注意:以下几个参数不属于-X参数

  • -Xms:设置初始Java堆大小,等价于-XX:InitialHeapSize
  • -Xmx:设置最大Java堆大小,等价于-XX:MaxHeapSize
  • -Xss:设置Java线程堆栈大小,等价于-XX:ThreadStackSize
2.1.3 类型三:-XX参数选项

作用:用于开发和调试JVM

分类:

  • 对于Boolean类型格式:
    • -XX:+
    • -XX:-
  • 对于非Boolean类型格式(key-value类型):
    • 数值型格式:-XX:
    • 非数值型格式:-XX=

2.2 常用的JVM参数选项

2.2.1 打印设置的XX选项及值
  • -XX:+PrintCommandLineFlags:让程序运行前打印出用户手动设置或者JVM自动设置的XX选项
  • -XX:+PrintFlagsInitial:表示打印出所有XX选项的默认值
  • -XX:+PrintFlagsFinal:表示打印出XX选项在运行程序时生效的值
  • -XX:+PrintVMOptions:打印JVM的参数
2.2.2 堆、栈、方法区等内存大小设置

对于栈内存:

  • -Xss:设置Java线程堆栈大小,等价于-XX:ThreadStackSize=size

对于堆内存:

  • -Xms:设置初始Java堆大小,等价于-XX:InitialHeapSize=size
  • -Xmx:设置最大Java堆大小,等价于-XX:MaxHeapSize=size
  • -Xmn:设置年轻代大小,官方推荐设置为整个堆大小的3/8
  • -XX:NewSize=size:设置年轻代初始值
  • -XX:MaxNewSize=:设置年轻代最大值
  • -XX:SurvivorRatio=:设置年轻代中Eden区和一个Survivor区的比值,默认值是8
  • -XX:+UseAdaptiveSizePolicy:自动选择各区大小比例
  • -XX:NewRatio=:设置老年代与年轻代(1个Eden区和2个Survivor区)的比值,默认值是2
  • -XX:PretenureSizeThreadshold=:设置让大于此阈值的对象直接分配在老年代,单位为字节(注意只对Serial、ParNew收集器有效)
  • -XX:MaxTenuringThreshold=
  • -XX:+PrintTenuringDistribution:让JVM在每次MinorGC后打印出当前Survivor中对象的年龄分布
  • -XX:TargetSurvivorRatio=:表示MinorGC结束后Survivor区域中占用空间的期望比例

对于方法区/元空间:

  • -XX:MetaspaceSize:设置初始空间大小
  • -XX:MaxMetaspaceSize:设置最大空间,默认没有限制
  • -XX:+UseCompressedOops:压缩对象指针
  • -XX:+UseCompressedClassPointers:压缩类指针
  • -XX:CompressedClassSpaceSize:设置Class Metaspace的大小,默认是1G
  • -XX:MaxDirectMemorySize:指定直接内存容量,若未指定默认与堆最大值一样
2.2.3 OutofMemory相关选项
  • -XX:+HeapDumpOnOutOfMemoryError:表示在内存出现OOM的时候,把Heap转储到文件(Dump文件)以便后续分析

  • -XX:+HeapDumpBeforeFullGC:表示在出现FullGC之前,生成Heap转储文件

  • -XX:HeapDumpPath=:指定Heap转储文件的存储路径

  • -XX:OnOutOfMemoryError:指定一个可行性程序或者脚本的路径,当发生OOM时去执行这个脚本

2.2.4 垃圾回收器相关选项

对于Parallel回收器:

  • -XX:ParallelGCThreads:设置年轻代并行回收器的线程数,一般最好与CPU数量相等
  • -XX:MaxGCPauseMillis:设置垃圾收集器最大停顿时间,单位是毫秒
  • -XX:GCTimeRatio:垃圾收集时间占总时间的比例,,默认垃圾回收时间不超过1%,用于衡量吞吐量的大小
  • -XX:+UseAdaptiveSizePolicy:设置Parallel收集器具有自适应调节策略

对于G1回收器:

  • -XX:MaxGCPauseMillis:设置期望达到的最大GC停顿时间指标,默认值是200ms
  • -XX:ParallelGCThread:设置STW时GC线程数的值,最多设置为8
  • -XX:ConcGCThreads:设置并发标记的线程数
  • -XX:InitiatingHeapOccupancyPercent:设置触发并发GC周期的Java堆占用率阈值,超过此值就触发GC,默认值是45
  • -XX:G1NewSizePercent、-XX:G1MaxNewSizePercent:新生代占用整个对内存的最小百分比(默认5%)、最大百分比(默认60%)
  • -XX:G1ReservePercent=:保留内存区域,防止Survivor中的to区溢出
2.2.5 GC日志相关选项
  • -verbose:gc:输出gc日志信息,默认输出到标准输出
  • -XX:+PrintGC:表示打开简化的GC日志
  • XX:+PrintGCDetails:打印内存回收详细日志并在进程退出时输出当前内存各区域分配情况
  • XX:+PrintGCTimeStamps:输出GC发生时的时间戳
  • XX:+PrintGCDataStamps:以日期格式输出GC发生时的时间戳
  • -XX:+PrintHeapAtGC:每一次GC前和GC后都打印堆信息
  • -Xloggc::把GC日志写入到一个文件中,而不是打印到标准输出中