文章目录
JVM负责Java程序的执行,那么从Java语句到执行程序,之间的过程是怎么发生的呢?
首先,我们提到JVM使Java语言具备跨平台性,其本质实现就是java源程序.class
文件编译后生成的.class
文件,其是JVM能识别的语言,那么为什么要编译呢?
- 可执行性:Java语言是高级语言,JVM并不认识这种语言,因此需要转变成JVM能识别的语言
- 移植性:通过编译成.class文件,能够实现一处编译、处处运行。
之后,就是JVM将能识别的代码通过解释器转换为特定系统的机器执行码。
1. 编译
上面说到,编译就是将java源代码转换成JVM能识别的字节码文件,整个过程如下图:
词法分析:
词法分析就是将源程序(可以认为是一个很长的字符串)读进来,并且“切”成小段(每一段就是一个词法单元 token),每个单元都是有具体的意义的,最后得到一个个“单词”。
比如int a = b + 2
,上面每一个数字或符号都是一个标记,因为他们都有具体的意义,且不可再分。
语法分析:
是对token流进行语法检查、并构建由输入的单词组成的数据结构(语法树/抽象语法树)。抽象语法树是一种描述程序代码语法结构的树形表示方式,比如类、方法、循环结构、接口、代码注释都可以是抽象语法树上的一个节点。
语义分析:
语法分析后,编译器获得了一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,比如进行类型检查,控制流检查,数据流检查,解语发糖。
字节码生成:
将注解语法树转换成字节码,并将字节码文件写入***.class**文件
静态绑定和动态绑定
绑定指的是一个方法的调用与方法所在的类(方法主体)关联起来,绑定分为静态绑定(前期绑定) 和 动态绑定(后期绑定)
静态绑定
静态绑定指的是在程序执行前就已经被绑定(编译过程就确定调用方法所属类),在Java中,只有final、static、private和构造方法
动态绑定(后期绑定)
动态绑定指定的是在运行时再决定这个方法由哪个对象调,这个过程就被成为动态绑定。比如看下面的方法,在编译器并不知道哪个对象调用,而只能在运行期间才能确定方法的调用对象,才能将方法加载到对用的区域。
class A{
public void say(int a){
System.out.println(a);
}
}
public class Singleton {
public static void main(String[] args) {
A a1 = new A();
A a2 = new A();
a1.say(1);
a2.say(2);
}
}
2. 类加载
有了.class
文件后,JVM就可以运行这些.class
文件了,由于.class文件是编译后的静态文件,因此JVM需要将其加载进来,并为执行程序前做准备(连接)。
2.1 类加载流程
加载:
在加载阶段, 虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 将类的class文件读入内存,并为之创建一个
java.lang.Class
对象,也就是说当程序中使用任何类时,系统都会为之建立一个java.lang.Class
对象, 作为方法区这个类的各种数据的访问入口。
连接:
当类被加载后,系统为之生成一个对应的Class对象,接着会进入连接阶段,连接阶段将会负责把类的二进制文件合并到JRE中。类连接分为如下三个阶段:
- 验证:验证是连接阶段的第一步, 这一阶段的目的是为了确保 Class文件的字节流中包含的信息符合当前虚拟机的要求, 井且不会危害虚拟机自身的安全。
- 文件格式校验:验证字节流是否符合 Class文件格式的规范, 井且能被当前版本的虚拟机处理
- 对类的元数据进行语义检验,如是否继承了final类、是否实现了接口的全部方法等,确保不存在不符合java语义的类
- 字节码验证:确保字节码流可以被Java虚拟机安全的执行。字节码流是操作码组成的序列。每一个操作码后面都会跟着一个或者多个操作数。字节码检查这个步骤会检查每一个操作码是否合法;
- 符号引用验证:对类自身以外(常量池中的各种符号引用) 的信息进行匹配性的校验,如符号引用的类能够找到,符号引用的字段能否访问
- 准备:正式为类变量(就是静态变量)分配内存并设置类变量初始值(默认值)的阶段,这些变量所使用的内存都将在**方法区(也称静态区)**中进行分配
- 解析:把类中的符号引用转化为直接引用(比如说方法的符号引用,是有方法名和相关描述符组成,在解析阶段,JVM把符号引用替换成一个指针,这个指针就是直接引用,它指向该类的该方法在方法区中的内存位置)
初始化:
在前面都是JVM控制的执行准备过程,从初始化开始,class代码中的语句开始执行。包括为静态变量赋值,执行静态代码块中的语句,执行父类静态代码块中语句等等,具体过程如下:父类静态代码块—>子类静态代码块。
那么有人就会问了,为什么这里不和其他代码放在一起执行呢,而非要先初始化呢?
原因是为了提升程序的性能,我们知道静态变量或静态代码块是类专属的,与对象无关,因此在程序运行期间只用准备一次即可,因此为了提高性能,提前在类加载时初始化静态代码块,并将静态数据存放在方法区(静态区)。
实例化:
前面我们提到了,程序运行期间静态代码块只会执行一次,静态变量也只与类相关,因此在类加载时就一起初始化了,并且单独放在方法区。接下来,程序执行必然用到类的对象,此时就要执行对象的实例化,实例化的实现和初始化可谓大不相同,具体的之后再说。
2.2 双亲加载机制
2.2.1 为什么提出双亲委派机制
面试官问,能不能自己写个类叫java.lang.system?
答案是不能,首先是编译器就会报错,其次我们如果写好了这个类,然后使用类加载器就加载它,也无法加载,因为类加载器必须继承ClassLoader
类,而类加载采用双亲委派机制,也就是父类能够加载的必须由父类先加载,因此父类会先加载系统的system类,子类也就无法加载自己书写的system类的。
有人会说,我通过重写父类加载器来打破双亲委派机制,不就可以了,实际上是不行,因为BootstrapClassLoader是JVM层面的,对于系统的核心类库都是通过JVM进行加载,如果写了和核心库相同的类,运行时会报错。
自定义的类加载器加载我们自定义的类
- 会调用自定义类加载器的loadClass方法
- 而我们自定义的classLoader必须继承ClassLoader,loadClass方***调用父类的defineClass方法
- 而父类的这个defineClass是一个final方法,无法被重写
- 所以自定义的classLoader是无论如何也不可能加载到以java.开头的类的
jvm如何认定两个对象属于同一类型
- 都是由同名的类完成实例化的
- 两个实例各自对应的同名的类的加载器必须是同一个。
所以为了避免重复加载和核心库被篡改,Java提出了双亲委派机制。
2.2.2 委派实现过程
在JVM中,类加载是由多个类加载器完成的,并且实现了一种“父委派机制”。
-
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,
-
如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,
-
如果父类加载器可以完成类加载任务,就成功返回
-
倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载
- 启动类加载器(Bootstrap ClassLoader):负责加载
JAVA_HOME\lib
目录下文件的类库 - 扩展类加载器(Extension ClassLoader):负责加载
JAVA_HOME\lib\ext
目录中的类库 - 应用程序类加载器(Application ClassLoader):负责加载用户路径(
classpath
)下的类库
特点:
- 全盘负责:当一个类加载器加载一个类时,该类所依赖的其他类也会被这个类加载器加载到内存中。
- 缓存机制:所有的Class对象都会被缓存,当程序需要使用某个Class时,类加载器先从缓存中查找,找不到,才从class文件中读取数据,转化成Class对象,存入缓存中。
那么如果说一个由父类加载器加载的类要使用由子类加载器记载的类,而起步就是父类加载器,其无法委托子类加载器来实现加载,现在怎么办呢?
2.3 破坏双亲委派
前面提到,双亲委派机制避免了重复类加载并保证了核心库的安全,但是有的代码库却破坏了双亲委派,这是为什么呢? 这要提到双亲委派的弊端:无法做到不委派,也无法向下委派。
2.3.1 JDBC破坏双亲委派
JDBC为什么要打破双亲委派
- JDBC的Driver接口定义在JDK中,其实现由各个数据库的服务商来提供,比如MySQL驱动包
- DriverManager 类中要加载各个实现了Driver接口的类,然后进行管理,但是DriverManager位于 $JAVA_HOME中jre/lib/rt.jar 包,由BootStrap类加载器加载,
- 而其Driver接口的实现类是位于服务商提供的 Jar 包,而无法利用Bootstrap类加载器加载
**根据类加载机制,当被装载的类引用了另外一个类的时候,虚拟机就会使用装载第一个类的类装载器装载被引用的类。**因此,需要定义子类加载去加载Driver实现,这就是向下委派,打破了双亲委派机制。
首先需要注意一点,JDBC4.0之前使用Class.forName("")方式加载驱动是不会破坏双亲委派的,在JDBC4.0之后使用spi机制才会破坏双亲委派机制。
Class.forName("")方式不破坏双亲委派机制
由于这段代码是写在我们调用方,由我们自己来加载驱动类,由于遵循全盘负责委托机制,他使用的必定是和我们调用类的加载器一样的 ApplicationClassLoader这个加载器。从 ApplicationClassLoader由下而上进行委派加载。
spi机制
SPI ,全称为 Service Provider Interface,是一种服务发现机制。它通过在ClassPath路径下的META-INF/services文件夹查找文件,自动加载文件里所定义的类。由于SPI是实现向下委派的,因此为很多框架扩展提供了可能,比如在Dubbo、JDBC中都使用到了SPI机制。下面是jdbc实例
java.sql.DriverManagement这个类的静态代码块去加载驱动类
public class DriverManager {
//省略多余代码
static {
//这个方法的具体逻辑则是从驱动包下META-INF/services文件中,加载里面写好的Driver的实现类。
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
}
// 但是,加载这个类的加载器使用的是线程上下文中的AppClassLoader,
// 这个加载器是由jvm启动时,调用Launcher的构造方法放入当前线程的
public class Launcher {
private static URLStreamHandlerFactory factory = new Launcher.Factory();
private static Launcher launcher = new Launcher();
private static String bootClassPath = System.getProperty("sun.boot.class.path");
private ClassLoader loader;
private static URLStreamHandler fileHandler;
public static Launcher getLauncher() {
return launcher;
}
public Launcher() {
Launcher.ExtClassLoader var1;
try {
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}
try {
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
因此,从上面可看到,JDBC中并不会是由Bootstrap ClassLoader负责加载驱动器类的,而是获取其他加载器,通过其他加载器来加载。
通过Thread.getContextClassLoader()
:
class SecuritySupport {
ClassLoader getContextClassLoader() throws SecurityException{
return (ClassLoader)
AccessController.doPrivileged(new PrivilegedAction() {
public Object run() {
ClassLoader cl = null;
//try {
//获得线程的上下文加载器
cl = Thread.currentThread().getContextClassLoader();
//} catch (SecurityException ex) { }
if (cl == null)
cl = ClassLoader.getSystemClassLoader();
return cl;
}
});
}
2.3.2 Tomcat破坏双亲委派
tomcat为什么要打破双亲委派
-
当Tomcat中部署的两个WEB应用,都有相同的包路径以及类名称,但是业务实现不同。安装JVM的双亲委派机制,可能存在互相覆盖的情况,因此Tomcat避免双亲委派而提出了WebAPP ClassLoader加载器。
-
当Tomcat类与WEB应用中的类有相同的包路径和类名称时,也需要隔离,单独各自加载。Tomcat 针对这些系统类提供了Catalina ClassLoader
-
实现热部署:修改jsp文件后,因为类名一样,默认的类加载器不会重新加载,而是使用方法区中已经存在的类;所以需要每个jsp对应一个唯一的类加载器,当修改jsp的时候,直接卸载唯一的类加载器,然后重新创建类加载器,并加载jsp文件,以此实现热加载
Tomcat的类加载机制:
不只是Driver驱动的实现是这样,在tomcat、spring等等的容器框架也是通过一些手段去绕过“父委派机制”。例如下图中的tomat类加载器的结构:
从图中可以看出,tomcat为了实现隔离性,违背了双亲加载机制,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。
2.3 类的卸载
-
有JVM自带的三种类加载器(根、扩展、系统)加载的类始终不会卸载。因为JVM始终引用这些类加载器,这些类加载器使用引用他们所加载的类,因此这些Class类对象始终是可到达的。
-
由用户自定义类加载器加载的类,是可以被卸载的。
3. 对象的创建
前面两篇文章,我们知道了java程序的编译、连接、初始化过程,并且了解了JVM运行起来后内存的布局,下面我们综合两部分知识,结合代码来推演对象的真实创建过程。
public class Person {
public static int staicVariabl=1;
public int objVariabl;
static {
staicVariabl=2;
}
{
objVariabl=88;
}
public Person() {
objVariabl=99;
}
public static void main(String[] args) {
Person person=new Person();
}
}
3.1 对象创建的过程
当我们new一个对象的时候JVM首先会去找到对应的类元信息,如果找不到意味着类信息还没有被加载,所以在对象创建的时候也可能会触发类的加载操作。当类元信息被加载之后,我们就可以通过类元信息来确定对象信息和需要申请的内存大小。
3.1.1 构建对象
首先main线程会在栈中申请一个自己的栈空间,然后调用main方法后会生成一个main方法的栈帧。然后执行new Person() ,这里会根据Person类元信息先确定对象的大小,向JVM堆中申请一块内存区域并构建对象,同时对Person对象成员变量初始化并赋默认值。
申请内存的过程
- JVM会试图为相关Java对象在年轻代的Eden中初始化一块内存区域;
- 当Eden空间足够时,内存申请结束。
- 否则JVM试图释放在Eden中所有不活跃的对象(minor collection),释放后若Eden空间仍然不足以放入新对象,则试图将部分Eden中活跃对象放入Survivor区;
- Survivor区被用来作为Eden及old的中间交换区域,如果Survivor不足以放置eden区的对象,如果老年代区有空闲,那么直接放置在old区,Survivor区的对象会被移到Old区
- 当老年代空间不够时,触发major GC
- 若Survivor及old区仍然无法存放从Eden复制过来的部分对象,则出现"Out of memory错误";
3.1.2 实例化对象:
然后执行对象内部生成的init方法,初始化成员变量值,同时执行搜集到的{}代码块逻辑,最后执行对象构造方法(init 方法执行完后objVariabl=88,构造方法执行完后objVariabl=99)。
3.1.3 引用对象
对象实例化完毕后,再把栈中的Person对象引用地址指向Person对象在堆内存中的地址。
3.2 对象的内存布局
一个对象在new出来之后在内存中主要分为4个部分:
- markword这部分其实就是加锁的核心,同时还包含的对象的一些生命信息,例如是否GC、经过了几次Young GC还存活。
- klass pointer记录了指向对象的class文件指针。
- instance data记录了对象里面的变量数据。
- padding作为对齐使用,对象在64位服务器版本中,规定对象内存必须要能被8字节整除,如果不能整除,那么就靠对齐来补。
java代码验证:
public class Test_1{
public static void main(String[] args) {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
可以看到,对象头包含了12个字节,前两行为markword,第三行为klass指针,第四行是数据行,在这里由于缺少数据而补全。
由此,可以推断对象在内存中,除数据对象外,还有12 bytes的头部信息,之外还要字节补全。
3.3 对象使用过程
3.3.1 对象的访问
我们知道,在Java语句中,对象的赋值只是将对象在堆内存中的引用赋值给虚拟机栈中栈帧对象,而 由于reference类型在Java虚拟机规范里只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java堆中的对象的具***置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄(类似二级指针)和直接指针。
句柄访问方式
Java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。使用句柄方式最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
直接指针式(HotSpot)
如果使用该方式,Java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址。使用直接指针方式最大的好处就是速度更快,他节省了一次指针定位的时间开销。
案例:Java代码的访问过程
//父类Animal
class Animal {
/*8、执行初始化*/
private int i = 9;
protected int j;
/*7、调用构造方法,创建默认属性和方法,完成后发现自己没有父类*/
public Animal() {
/*9、执行构造方法剩下的内容,结束后回到子类构造函数中*/
System.out.println("i = " + i + ", j = " + j);
j = 39;
}
/*2、初始化根基类的静态对象和静态方法*/
private static int x1 = print("static Animal.x1 initialized");
static int print(String s) {
System.out.println(s);
return 47;
}
}
//子类 Dog
public class Dog extends Animal {
/*10、初始化默认的属性和方法*/
private int k = print("Dog.k initialized");
/*6、开始创建对象,即分配存储空间->创建默认的属性和方法。 * 遇到隐式或者显式写出的super()跳转到父类Animal的构造函数。 * super()要写在构造函数第一行 */
public Dog() {
/*11、初始化结束执行剩下的语句*/
System.out.println("k = " + k);
System.out.println("j = " + j);
}
/*3、初始化子类的静态对象静态方法,当然mian函数也是静态方法*/
private static int x2 = print("static Dog.x2 initialized");
/*1、要执行静态main,首先要加载Dog.class文件,加载过程中发现有父类Animal, *所以也要加载Animal.class文件,直至找到根基类,这里就是Animal*/
public static void main(String[] args) {
/*4、前面步骤完成后执行main方法,输出语句*/
System.out.println("Dog constructor");
/*5、遇到new Dog(),调用Dog对象的构造函数*/
Dog dog = new Dog();
/*12、运行main函数余下的部分程序*/
System.out.println("Main Left");
}
}
// 结果
static Animal.x1 initialized
static Dog.x2 initialized
Dog constructor
i = 9, j = 0
Dog.k initialized
k = 47
j = 39
Main Left
4. 对象引用
在JDK 1.2以前的版本中,若一个对象不被任何变量引用,那么程序就无法再使用这个对象。也就是说,只有对象处于可触及(reachable)状态,程序才能使用它。
从JDK 1.2版本开始,把对象的引用分为4种级别,从而使程序能更加灵活地控制对象的生命周期。 这4种级别由高到低依次为:强引用、软引用、弱引用和虚引用。
4.1 引用分类
4.1.1 强引用
强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。 ps:强引用其实也就是我们平时A a = new A()这个意思。
4.1.2 软引用
如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。 只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存(下文给出示例)。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。
软引用非常适合于创建缓存。 当系统内存不足的时候,缓存中的内容是可以被释放的。比如考虑一个图像编辑器的程序。该程序会把图像文件的全部内容都读取到内存中,以方便进行处理。而用户也可以同时打开多个文件。当同时打开的文件过多的时候,就可能造成内存不足。如果使用软引用来指向图像文件内容的话,垃圾回收器就可以在必要的时候回收掉这些内存。
package com.reference.test;
import java.lang.ref.SoftReference;
public class TestSoftReference {
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed");
}
public static void main(String[] args) {
TestSoftReference tsr = new TestSoftReference();
System.out.println("tsr instance: " + tsr);
SoftReference sr = new SoftReference(tsr);
/** * 此时TestSoftReference的一个对象有两个引用指向它: * 1. 一个强引用tsr * 2. 一个软引用sr */
System.out.println("before gc: " + sr.get());
tsr = null; // 此时只有一个软引用sr指向该对象
System.gc(); // 启动垃圾回收器
System.out.println("after gc: " + sr.get());
}
}
结果如下: 可以看到只存在一个软引用指向该对象时, 即使主动调用System.gc()
方法也没有回收该对象的内存空间.
4.1.3 弱引用
用来描述非必需对象的,但是它的强度比软引用更弱一些, 被弱引用关联的对象只能生存到下一次垃圾收集发生之前. 当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象.
在JDK 1.2
之后,提供了WeakReference
类来实现弱引用.
import java.lang.ref.WeakReference;
public class TestWeakReference {
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed");
}
public static void main(String[] args) {
TestWeakReference twr = new TestWeakReference();
WeakReference wr = new WeakReference(twr);
/** * 此时TestSoftReference的一个对象有两个引用指向它: * 1. 一个强引用twr * 2. 一个弱引用sr */
System.out.println("before gc: " + wr.get());
twr = null; //去掉强引用twr
System.gc();
System.out.println("after gc: " + wr.get());
}
}
结果如下: 可以看到弱引用已经被回收了.
弱引用最常见的用途是实现规范映射(canonicalizing mappings,比如哈希表)。
弱引用的作用在于解决强引用所带来的对象之间在存活时间上的耦合关系。弱引用最常见的用处是在集合类中,尤其在哈希表中。哈希表的接口允许使用任何Java对象作为键来使用。当一个键值对被放入到哈希表中之后,哈希表对象本身就有了对这些键和值对象的引用。如果这种引用是强引用的话,那么只要哈希表对象本身还存活,其中所包含的键和值对象是不会被回收的。如果某个存活时间很长的哈希表中包含的键值对很多,最终就有可能消耗掉JVM中全部的内存。
对于这种情况的解决办法就是使用弱引用来引用这些对象,这样哈希表中的键和值对象都能被垃圾回收。Java中提供了WeakHashMap来满足这一常见需求。
4.1.4 虚引用
虚引用是最弱的一种引用关系. 一个对象是否有虚引用的存在, 完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例. 为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知. 在JDK 1.2
之后, 提供了PhantomReference
类来实现虚引用.
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
public class TestPhantomReference {
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed");
}
public static void main(String[] args) {
ReferenceQueue rq = new ReferenceQueue();
TestWeakReference twr = new TestWeakReference();
PhantomReference pr = new PhantomReference(twr, rq);
System.out.println("before gc: " + pr.get() + ", " + rq.poll());
twr = null; //去掉强引用twr
System.gc();
System.out.println("after gc: " + pr.get() + "," + rq.poll());
}
}
虚引用仅仅只是提供了一种确保对象被finalize以后来做某些事情的机制。比如说这个对象被回收之后发一个系统通知啊啥的。虚引用是必须配合ReferenceQueue 使用的,具体使用方法和上面提到软引用的一样。主要用来跟踪对象被垃圾回收的活动。
幽灵引用及其队列的使用情况并不多见,主要用来实现比较精细的内存使用控制,这对于移动设备来说是很有意义的。程序可以在确定一个对象要被回收之后,再申请内存创建新的对象。通过这种方式可以使得程序所消耗的内存维持在一个相对较低的数量。