Java虚拟机(JVM)面试题1

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: Java虚拟机(JVM)面试题

JVM

说一下 JVM 的主要组成部分及其作用?


总体来说,方法区和堆是所有线程共享的内存区域;而虚拟机栈、本地方法栈和程序计数器的运行是线程私有的内存区域,运行时数据区域就是我们常说的JVM的内存


类加载子系统:根据给定的全限定名类名(如:java.lang.Object)来装载class文件到运行时数据区中的方法区中

Java堆是Java虚拟机所管理的内存中最大的一块,也是垃圾回收的主要区域。堆就是给对象分配内存,用来存放对象实例

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据(注意这里没有局部变量,局部变量是存在栈中的)

程序计数器是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器,存储执行引擎下一条执行指令的地址。

虚拟机栈描述的是Java方法执行的内存模型:每个java方法被执行的时候都会同时在虚拟机栈中创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、返回方法地址等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。

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

执行引擎(解释器):根据程序计数器中存储的指令地址执行classes中的指令

本地接口:与本地方法库交互,是其它编程语言交互的接口。


JVM各个组成部分的作用

首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader) 再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能(调用本地库接口因为不同的操作系统的底层是不同的语言写的,然后虚拟机要把这个class字节码转为对应操作系统的机器码就需要调用本地库接口来调用本地库方法,里面有其他语言的交互)


Java程序运行机制详细说明

简单来说Java程序的运行机制分为编写、编译和运行三个步骤。

1.编写


编写是指在Java开发环境中进行程序代码的编辑,最终生成后缀名为“.java”的Java源文件。


2.编译


编译是指使用Java编译器对源文件进行错误排查的过程,编译后将生成后缀名为.class的字节码文件,该文件可以被Java虚拟机(JVM)的解释器正常读取。


3.运行

运行是指使用Java解释器将字节码文件翻译成机器代码,执行并显示结果。字节码文件是一种和任何具体机器环境及操作系统环境无关的中间代码,它是一种二进制文件,是Java源文件由Java编译器编译后生成的目标代码文件。编程人员和计算机都无法直接读懂字节码文件,它必须由专用的Java解释器来解释执行,因此Java是一种在编译基础上进行解释运行的语言。


在运行Java程序时,首先会启动JVM,然后由它来负责解释执行Java的字节码,并且Java字节码只能运行于JVM之上。这样利用JVM就可以把Java字节码程序和具体的硬件平台以及操作系统环境分隔开来,只要在不同的计算机上安装了针对于特定具体平台的JVM,Java程序就可以运行,而不用考虑当前具体的硬件平台及操作系统环境,也不用考虑字节码文件是在何种平台上生成的。JVM把这种不同软硬件平台的具体差别隐藏起来,从而实现了真正的二进制代码级的跨平台移植。JVM是Java平台无关的基础,Java的跨平台特性正是通过在JVM中运行Java程序实现的。接下来了解一下Java的运行流程,如图所示。

Java运行流程

图中,从编写出来的Java源文件,到编译为字节码文件,再到通过JVM的解释器解释成不同平台的机器语言,这些机器语言最后在不同的平台上执行得到java程序,然后将程序的运行结果展示给用户,这是一个完整的Java运行流程。


说一下 JVM 运行时数据区

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

不同虚拟机的运行时数据区可能略微有所不同,但都会遵从 Java 虚拟机规范, Java 虚拟机规范规定的区域分为以下 5 个部分:


程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成;

Java 虚拟机栈(Java Virtual Machine Stacks):用于存储局部变量表、操作数栈、动态链接、方法出口等信息;

本地方法栈(Native Method Stack):与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的;

Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存;

方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。


深拷贝和浅拷贝

浅复制:仅仅是指向被复制的内存地址,如果原地址发生改变,那么浅复制出来的对象也会相应的改变。

深复制:在计算机中开辟一块新的内存地址用于存放复制的对象。


浅拷贝

浅拷贝(shallowCopy)只是增加了一个指针指向已存在的内存地址,通过浅拷贝复制一个新对象,把这个新创建的对象的引用指向原来对象的内存地址,也就是新对象和原来的对象都是用的同一个对象,两个的引用指向同一个对象。所以修改新对象的属性值会修改原来对象的属性值(浅拷贝:创建一个新对象,然后将当前对象的非静态字段复制到该新对象,如果字段是值类型的,那么对该字段执行复制;如果该字段是引用类型的话,则复制引用但不复制引用的对象。因此,原始对象及其副本引用同一个对象。)


浅拷贝演示


package com.ys.test;
public class Person implements Cloneable{
    public String pname;
    public int page;
    public Address address;
    public Person() {}
    public Person(String pname,int page){
        this.pname = pname;
        this.page = page;
        this.address = new Address();
    }
    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
    public void setAddress(String provices,String city ){
        address.setAddress(provices, city);
    }
    public void display(String name){
        System.out.println(name+":"+"pname=" + pname + ", page=" + page +","+ address);
    }
    public String getPname() {
        return pname;
    }
    public void setPname(String pname) {
        this.pname = pname;
    }
    public int getPage() {
        return page;
    }
    public void setPage(int page) {
        this.page = page;
    }
}
package com.ys.test;
public class Address {
    private String provices;
    private String city;
    public void setAddress(String provices,String city){
        this.provices = provices;
        this.city = city;
    }
    @Override
    public String toString() {
        return "Address [provices=" + provices + ", city=" + city + "]";
    }
}


下面我们产生一个 Person 对象,并调用其 clone 方法复制一个新的对象。

注意:Object 类提供的 clone(渴 漏) 是只能实现浅拷贝的,调用对象的 clone 方法,必须要让类实现 Cloneable 接口,并且覆写 clone 方法。

@Test
public void testShallowClone() throws Exception{
    Person p1 = new Person("zhangsan",21);
    p1.setAddress("湖北省", "武汉市");
    Person p2 = (Person) p1.clone();
    System.out.println("p1:"+p1);
    System.out.println("p1.getPname:"+p1.getPname().hashCode());
    System.out.println("p2:"+p2);
    System.out.println("p2.getPname:"+p2.getPname().hashCode());
    p1.display("p1");
    p2.display("p2");
    p2.setAddress("湖北省", "荆州市");
    System.out.println("将复制之后的对象地址修改:");
    p1.display("p1");
    p2.display("p2");
}


运行结果


首先看原始类 Person 实现 Cloneable 接口,并且覆写 clone 方法,它还有三个属性,一个引用类型 String定义的 pname,一个基本类型 int定义的 page,还有一个引用类型 Address ,这是一个自定义类,这个类也包含两个属性 pprovices 和 city 。


接着看测试内容,首先我们创建一个Person 类的对象 p1,其pname 为zhangsan,page为21,地址类 Address 两个属性为 湖北省和武汉市。接着我们调用 clone() 方法复制另一个对象 p2,接着打印这两个对象的内容。


从第 1 行和第 3 行打印结果:


p1:com.ys.test.Person@349319f9


p2:com.ys.test.Person@258e4566


可以看出这是两个不同的对象。


从第 5 行和第 6 行打印的对象内容看,原对象 p1 和克隆出来的对象 p2 内容完全相同。


代码中我们只是更改了克隆对象 p2 的属性 Address 为湖北省荆州市(原对象 p1 是湖北省武汉市) ,但是从第 7 行和第 8 行打印结果来看,原对象 p1 和克隆对象 p2 的 Address 属性都被修改了。


也就是说对象 Person 的属性 Address,经过 clone 之后,其实只是复制了其引用,他们指向的还是同一块堆内存空间,当修改其中一个对象的属性 Address,另一个也会跟着变化。


深拷贝

深拷贝(deepCopy)是增加了一个指针,同时申请了一个新的内存,并且把新增加的指针指向新的内存,使用深拷贝的情况下,释放内存的时候不会出现浅拷贝释放同一个内存的错误,通过深拷贝创建一个新对象,在堆中重新开一片内存,把原来的对象给复制一份,然后把这个新对象的引用指向这个新开辟的内存地址。当你修改其中一个对象的任何内容时,都不会影响另一个对象的内容。(深拷贝:创建一个新对象,然后将当前对象的非静态字段复制到该新对象,无论该字段是值类型的还是引用类型,都复制独立的一份。当你修改其中一个对象的任何内容时,都不会影响另一个对象的内容。)


类装载方式

Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,而它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。


类装载方式有两种 :

(1)隐式装载


程序在运行过程中当碰到通过new等方式生成类或者子类对象、使用类或者子类的静态域时,隐式调用类加载器加载对应的的类到JVM中。


(2)显式装载


通过调用Class.forName()或者ClassLoader.loadClass(className)等方法,显式加载需要的类。


什么是类加载器?类加载器有哪些?

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在 JVM 中的唯一性,每一个类加载器,都有一个独立的类名称空间。类加载器就是根据指定全限定名称将 class 文件加载到JVM 内存,然后再转化为 class 对象。

主要有一下四种类加载器:


1.启动类加载器(Bootstrap ClassLoader)用来加载java核心类库,无法被java程序直接引用(是虚拟机自身的一部分,用来加载Java_HOME/lib/目录中的,或者被 -Xbootclasspath 参数所指定的路径中并且被虚拟机识别的类库)

2.扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类(负责加载\lib\ext目录或Java. ext. dirs系统变量指定的路径中的所有类库)

3.应用程序类加载器(system class loader):它根据 Java 应用的类路径

(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以 通过 ClassLoader.getSystemClassLoader()来获取它(负责加载用户类路径


(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没 有自定义类加载器默认就是用这个加载器)


1.自定义类加载器,通过继承 java.lang.ClassLoader类的方式实现


什么是双亲委派模型?为什么要使用双亲委派模型?

1、什么是双亲委派模型 ?

双亲委派机制:在加载一个类的时候首先检查这个类是不是已经被加载过了,如果加载过了直接返回,否则委派给父加载器加载,这是一个递归调用,一层一层向上委派(如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归),请求最终将到达顶层的类加载器。 如果最顶层的类加载器(启动类加载器)可以完成类加载任务,就成功返回,无法加载该类时,再一层一层向下委派给子类加载器加载。


2、为什么要使用双亲委派模型 ?

双亲委派保证每一个类在各个类加载器中都是同一个类,一个非常明显的目的就是保证java官方的类库<JAVA_HOME>\lib和扩展类库<JAVA_HOME>\lib\ext的加载安全性,不会被开发者覆盖,说白了就是保证类的唯一性。


如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类,多个类加载器都去加载这个类到内存中,系统中将会出现多个不同的Object类。


例如类java.lang.Object,它存放在rt.jar之中,无论哪个类加载器要加载这个类,最终都是委派给启动类加载器加载(用双亲委派模型的话任何类就只会有一个类加载器来进行加载,因为他会先向上委派,再向下委派,直到找到唯一的类加载器来进行加载这个类),因此Object类在程序的各种类加载器环境中都是同一个类。


如果开发者自己开发开源框架,也可以自定义类加载器,利用双亲委派模型,保护自己框架需要加载的类不被应用程序覆盖。双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。这个委派和加载顺序完全是可以被破坏的。如果想自定义类加载器,就需要继承ClassLoader,并重写findClass,如果想不遵循双亲委派的类加载顺序,还需要重写loadClass。


扩展:一个类的唯一性由加载它的类加载器和这个类的本身决定(类的全限定名+类加载器的实例ID作为唯一标识)。比较两个类是否相等(包括Class对象的equals()、isAssignableFrom()、isInstance()以及instanceof关键字等),只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,这两个类就必定不相等。


说一下 JVM 调优的工具?

JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是jconsole(j 哼 so4) 和 jvisualvm (j 为 so3 wen3)这两款视图监控工具。


jconsole:用于对 JVM 中的内存、线程和类等进行监控;


jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等。


说一下 JVM 的作用?

首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用本地库接口(Native Interface),本地库接口会和本地方法库进行交互来调用其他语言(为什么要调用这个本地库接口呢?我的理解是不同的平台系统的底层是由不同的语言写的,然后class字节码要通过解析器来转成不同平台的可执行的机器码,就需要调用这个本地库接口)


说一下堆栈的区别?

堆的物理地址分配对象是不连续的。因此性能慢些。栈的物理地址分配是连续的。所以性能快。

堆分配的内存是在运行期确认的,大小不固定。一般堆大小远远大于栈。 栈分配的内存大小要在编译期就确认,大小是固定的。

堆存放的是对象的实例和数组。因此该区更关注的是数据的存储,栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。

堆对于整个应用程序都是共享、可见的。 栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。

补充:静态变量放在方法区 ,静态的对象还是放在堆。


JAVA类加载过程/类装载的执行过程/java类加载机制/JVM加载Class文件的原理机制?

类加载的过程主要分为三个部分:(加链初,验准解)

  • 加载
  • 链接
  • 初始化

而链接又可以细分为三个小部分:

  • 验证
  • 准备
  • 解析


类装载的执行过程总结:首先把class字节码文件从各个来源通过类加载器装载入内存中(加载),然后验证加载进来的字节码是否符合虚拟机规范(验证),Java虚拟机为类变量分配内存,并且赋予默认初始值(准备),虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址,也就是直接引用(解析),执行类构造器对类变量进行自定义的初始化(初始化)


1、加载

简单来说,加载指的是把class字节码文件从各个来源通过类加载器装载入内存中。

这里有两个重点:

  • 字节码来源
  • 一般的加载来源包括从本地路径下编译生成的.class文件
  • 从jar包中的.class文件
  • 从远程网络中获取
  • 动态代理实时编译
  • 类加载器
  • 启动类加载器
  • 扩展类加载器
  • 应用类加载器
  • 自定义类加载器


注:为什么会有自定义类加载器?


一方面是由于java代码很容易被反编译,如果需要对自己的代码加密的话,可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载。

另一方面也有可能从非标准的来源加载代码,比如从网络来源,那就需要自己实现一个类加载器,从指定源进行加载。


2、验证

主要是为了保证加载进来的字节码是否符合虚拟机规范


对于文件格式的验证,比如常量中是否有不被支持的常量?文件中是否有不规范的或者附加的其他信息?

对于元数据的验证,比如该类是否继承了被final修饰的类?类中的字段,方法是否与父类冲突?是否出现了不合理的重载?

对于字节码的验证,保证程序语义的合理性,比如要保证类型转换的合理性。

对于符号引用的验证,比如校验符号引用中通过全限定名是否能够找到对应的类?校验符号引用中的访问性(private,public等)是否可被当前类访问?

3、准备(默认初始化)

主要是为类变量分配内存,并且赋予默认初始值


特别需要注意,初值不是代码中具体写的初始化的值,而是Java虚拟机根据不同变量类型的默认初始值。


比如8种基本类型的初值,默认为0;引用类型的初值则为null;常量的初值即为代码中设置的值,final static tmp = 456, 那么该阶段tmp的初值就是456


4、解析

将常量池内的符号引用替换为直接引用的过程。


两个重点:


符号引用。即一个字符串,但是这个字符串给出了一些能够唯一性识别一个方法,一个变量,一个类的相关信息。

直接引用。可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针;而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量


举个例子来说,现在调用方法hello(),这个方法的地址是1234567,那么hello就是符号引用,1234567就是直接引用。

在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。


5、初始化(自定义初始化)

这个阶段主要是对类变量进行自定义的初始化,是执行类构造器的过程。其实就是根据程序员自己设置的值对类变量进行赋值


int a = 5;

在准备阶段就是为这个a变量分配内存空间,并且赋其默认值,也就是准备阶段a=0,因为int类型的默认值为0,然后在初始化阶段就是把程序员设置的值赋值给对应的类变量,所以把5赋值给a,a=5。


注意:类加载的几个阶段都只针对类变量,所以类变量以外的变量赋值不会在类加载过程中体现


目录
相关文章
|
6天前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
21 2
|
11天前
|
存储 算法 Java
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
本文详解自旋锁的概念、优缺点、使用场景及Java实现。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
|
16天前
|
存储 缓存 Oracle
Java I/O流面试之道
NIO的出现在于提高IO的速度,它相比传统的输入/输出流速度更快。NIO通过管道Channel和缓冲器Buffer来处理数据,可以把管道当成一个矿藏,缓冲器就是矿藏里的卡车。程序通过管道里的缓冲器进行数据交互,而不直接处理数据。程序要么从缓冲器获取数据,要么输入数据到缓冲器。
Java I/O流面试之道
|
13天前
|
存储 缓存 Java
大厂面试必看!Java基本数据类型和包装类的那些坑
本文介绍了Java中的基本数据类型和包装类,包括整数类型、浮点数类型、字符类型和布尔类型。详细讲解了每种类型的特性和应用场景,并探讨了包装类的引入原因、装箱与拆箱机制以及缓存机制。最后总结了面试中常见的相关考点,帮助读者更好地理解和应对面试中的问题。
38 4
|
13天前
|
存储 Java 程序员
Java基础的灵魂——Object类方法详解(社招面试不踩坑)
本文介绍了Java中`Object`类的几个重要方法,包括`toString`、`equals`、`hashCode`、`finalize`、`clone`、`getClass`、`notify`和`wait`。这些方法是面试中的常考点,掌握它们有助于理解Java对象的行为和实现多线程编程。作者通过具体示例和应用场景,详细解析了每个方法的作用和重写技巧,帮助读者更好地应对面试和技术开发。
54 4
|
1月前
|
存储 安全 算法
Java面试题之Java集合面试题 50道(带答案)
这篇文章提供了50道Java集合框架的面试题及其答案,涵盖了集合的基础知识、底层数据结构、不同集合类的特点和用法,以及一些高级主题如并发集合的使用。
86 1
Java面试题之Java集合面试题 50道(带答案)
|
26天前
|
存储 Java 程序员
Java面试加分点!一文读懂HashMap底层实现与扩容机制
本文详细解析了Java中经典的HashMap数据结构,包括其底层实现、扩容机制、put和查找过程、哈希函数以及JDK 1.7与1.8的差异。通过数组、链表和红黑树的组合,HashMap实现了高效的键值对存储与检索。文章还介绍了HashMap在不同版本中的优化,帮助读者更好地理解和应用这一重要工具。
53 5
|
25天前
|
存储 Java
[Java]面试官:你对异常处理了解多少,例如,finally中可以有return吗?
本文介绍了Java中`try...catch...finally`语句的使用细节及返回值问题,并探讨了JDK1.7引入的`try...with...resources`新特性,强调了异常处理机制及资源自动关闭的优势。
19 1
|
26天前
|
存储 算法 Java
Java虚拟机(JVM)的内存管理与性能优化
本文深入探讨了Java虚拟机(JVM)的内存管理机制,包括堆、栈、方法区等关键区域的功能与作用。通过分析垃圾回收算法和调优策略,旨在帮助开发者理解如何有效提升Java应用的性能。文章采用通俗易懂的语言,结合具体实例,使读者能够轻松掌握复杂的内存管理概念,并应用于实际开发中。
|
1月前
|
Java 程序员
Java 面试高频考点:static 和 final 深度剖析
本文介绍了 Java 中的 `static` 和 `final` 关键字。`static` 修饰的属性和方法属于类而非对象,所有实例共享;`final` 用于变量、方法和类,确保其不可修改或继承。两者结合可用于定义常量。文章通过具体示例详细解析了它们的用法和应用场景。
28 3