大家好,我是易安!今天我们简单聊下Java线程这个话题。
在Java领域,实现并发程序的主要手段就是多线程。线程是操作系统里的一个概念,虽然各种不同的开发语言如Java、C#等都对其进行了封装,但是万变不离操作系统。Java语言里的线程本质上就是操作系统的线程,它们是一一对应的。
在操作系统层面,线程也有“生老病死”,专业的说法叫有生命周期。对于有生命周期的事物,要学好它,思路非常简单,只要能搞懂 生命周期中各个节点的状态转换机制 就可以了。
虽然不同的开发语言对于操作系统线程进行了不同的封装,但是对于线程的生命周期这部分,基本上是雷同的。所以,我们可以先来了解一下通用的线程生命周期模型,这部分内容也适用于很多其他编程语言;然后再详细有针对性地学习一下Java中线程的生命周期。
通用的线程生命周期
通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是: 初始状态、可运行状态、运行状态、休眠状态 和 终止状态。通常我们描述的五态模型来源于操作系统的定义,这里我极力推荐你去阅读下关于操作系统核心精髓中的关于线程的一章。
通用线程状态转换图——五态模型
这“五态模型”的详细情况如下所示。
-
初始状态,指的是线程已经被创建,但是还不允许分配CPU执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。 -
可运行状态,指的是线程可以分配CPU执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配CPU执行。 -
当有空闲的CPU时,操作系统会将其分配给一个处于可运行状态的线程,被分配到CPU的线程的状态就转换成了 运行状态。 -
运行状态的线程如果调用一个阻塞的API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到 休眠状态,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。 -
线程执行完或者出现异常就会进入 终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。
这五种状态在不同编程语言里会有简化合并。例如,C语言的POSIX Threads规范,就把初始状态和可运行状态合并了;Java语言里则把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而JVM层面不关心这两个状态,因为JVM把线程调度交给操作系统处理了。
除了简化合并,这五种状态也有可能被细化,比如,Java语言里就细化了休眠状态(这个下面我们会详细讲解)。
Java中线程的生命周期
介绍完通用的线程生命周期模型,想必你已经对线程的“生老病死”有了一个大致的了解。那接下来我们就来详细看看Java语言里的线程生命周期是什么样的。
Java语言中线程共有六种状态,分别是:
-
NEW(初始化状态) -
RUNNABLE(可运行/运行状态) -
BLOCKED(阻塞状态) -
WAITING(无时限等待) -
TIMED_WAITING(有时限等待) -
TERMINATED(终止状态)
这看上去挺复杂的,状态类型也比较多。但其实在操作系统层面,Java线程中的BLOCKED、WAITING、TIMED_WAITING是一种状态,即前面我们提到的休眠状态。也就是说 只要Java线程处于这三种状态之一,那么这个线程就永远没有CPU的使用权。
所以Java线程的生命周期可以简化为下图:
Java中的线程状态转换图
其中,BLOCKED、WAITING、TIMED_WAITING可以理解为线程导致休眠状态的三种原因。那具体是哪些情形会导致线程从RUNNABLE状态转换到这三种状态呢?而这三种状态又是何时转换回RUNNABLE的呢?以及NEW、TERMINATED和RUNNABLE状态是如何转换的?
1. RUNNABLE与BLOCKED的状态转换
只有一种场景会触发这种转换,就是线程等待synchronized的隐式锁。synchronized修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从RUNNABLE转换到BLOCKED状态。而当等待的线程获得synchronized隐式锁时,就又会从BLOCKED转换到RUNNABLE状态。
如果你熟悉操作系统线程的生命周期的话,可能会有个疑问:线程调用阻塞式API时,是否会转换到BLOCKED状态呢?在操作系统层面,线程是会转换到休眠状态的,但是在JVM层面,Java线程的状态不会发生变化,也就是说Java线程的状态会依然保持RUNNABLE状态。 JVM层面并不关心操作系统调度相关的状态,因为在JVM看来,等待CPU使用权(操作系统层面此时处于可执行状态)与等待I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了RUNNABLE状态。
而我们平时所谓的Java在调用阻塞式API时,线程会阻塞,指的是操作系统线程的状态,并不是Java线程的状态。
2. RUNNABLE与WAITING的状态转换
总体来说,有三种场景会触发这种转换。
第一种场景,获得synchronized隐式锁的线程,调用无参数的Object.wait()方法。其中,wait()方法我们在上一篇讲解管程的时候已经深入介绍过了,这里就不再赘述。
第二种场景,调用无参数的Thread.join()方法。其中的join()是一种线程同步方法,例如有一个线程对象thread A,当调用A.join()的时候,执行这条语句的线程会等待thread A执行完,而等待中的这个线程,其状态会从RUNNABLE转换到WAITING。当线程thread A执行完,原来等待它的线程又会从WAITING状态转换到RUNNABLE。
第三种场景,调用LockSupport.park()方法。其中的LockSupport对象,也许你有点陌生,其实Java并发包中的锁,都是基于它实现的。调用LockSupport.park()方法,当前线程会阻塞,线程的状态会从RUNNABLE转换到WAITING。调用LockSupport.unpark(Thread thread)可唤醒目标线程,目标线程的状态又会从WAITING状态转换到RUNNABLE。
3. RUNNABLE与TIMED_WAITING的状态转换
有五种场景会触发这种转换:
-
调用 带超时参数 的Thread.sleep(long millis)方法; -
获得synchronized隐式锁的线程,调用 带超时参数 的Object.wait(long timeout)方法; -
调用 带超时参数 的Thread.join(long millis)方法; -
调用 带超时参数 的LockSupport.parkNanos(Object blocker, long deadline)方法; -
调用 带超时参数 的LockSupport.parkUntil(long deadline)方法。
这里你会发现TIMED_WAITING和WAITING状态的区别,仅仅是触发条件多了 超时参数。
4. 从NEW到RUNNABLE状态
Java刚创建出来的Thread对象就是NEW状态,而创建Thread对象主要有两种方法。一种是继承Thread对象,重写run()方法。(为了照顾初学同学,我先讲述这种,后面会有更多章节介绍JUC内容)示例代码如下:
// 自定义线程对象
class MyThread extends Thread {
public void run() {
// 线程需要执行的代码
......
}
}
// 创建线程对象
MyThread myThread = new MyThread();
另一种是实现Runnable接口,重写run()方法,并将该实现类作为创建Thread对象的参数。示例代码如下:
// 实现Runnable接口
class Runner implements Runnable {
@Override
public void run() {
// 线程需要执行的代码
......
}
}
// 创建线程对象
Thread thread = new Thread(new Runner());
NEW状态的线程,不会被操作系统调度,因此不会执行。Java线程要执行,就必须转换到RUNNABLE状态。从NEW状态转换到RUNNABLE状态很简单,只要调用线程对象的start()方法就可以了,示例代码如下:
MyThread myThread = new MyThread();
// 从NEW状态转换到RUNNABLE状态
myThread.start();
5. 从RUNNABLE到TERMINATED状态
线程执行完 run() 方法后,会自动转换到TERMINATED状态,当然如果执行run()方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断run()方法的执行,例如 run()方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?Java的Thread类里面倒是有个stop()方法,不过已经标记为@Deprecated,所以不建议使用了。正确的姿势其实是调用interrupt()方法。
那stop()和interrupt()方法的主要区别是什么呢?
stop()方法会真的杀死线程,不给线程喘息的机会,如果线程持有ReentrantLock锁,被stop()的线程并不会自动调用ReentrantLock的unlock()去释放锁,那其他线程就再也没机会获得ReentrantLock锁,这实在是太危险了。所以该方法就不建议使用了,类似的方法还有suspend() 和 resume()方法,这两个方法同样也都不建议使用了,所以这里也就不多介绍了。
而interrupt()方法就温柔多了,interrupt()方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。被interrupt的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。
当线程A处于WAITING、TIMED_WAITING状态时,如果其他线程调用线程A的interrupt()方法,会使线程A返回到RUNNABLE状态,同时线程A的代码会触发InterruptedException异常。上面我们提到转换到WAITING、TIMED_WAITING状态的触发条件,都是调用了类似wait()、join()、sleep()这样的方法,我们看这些方法的签名,发现都会throws InterruptedException这个异常。这个异常的触发条件就是:其他线程调用了该线程的interrupt()方法。
当线程A处于RUNNABLE状态时,并且阻塞在java.nio.channels.InterruptibleChannel上时,如果其他线程调用线程A的interrupt()方法,线程A会触发java.nio.channels.ClosedByInterruptException这个异常;而阻塞在java.nio.channels.Selector上时,如果其他线程调用线程A的interrupt()方法,线程A的java.nio.channels.Selector会立即返回。
上面这两种情况属于被中断的线程通过异常的方式获得了通知。还有一种是主动检测,如果线程处于RUNNABLE状态,并且没有阻塞在某个I/O操作上,例如中断计算圆周率的线程A,这时就得依赖线程A主动检测中断状态了。如果其他线程调用线程A的interrupt()方法,那么线程A可以通过isInterrupted()方法,检测是不是自己被中断了。
生命周期的应用
理解Java线程的各种状态以及生命周期对于诊断多线程Bug非常有帮助,多线程程序很难调试,出了Bug基本上都是靠日志,靠线程dump来跟踪问题,分析线程dump的一个基本功就是分析线程状态,大部分的死锁、饥饿、活锁问题都需要跟踪分析线程的状态。同时,上面介绍的线程生命周期具备很强的通用性,对于学习其他语言的多线程编程也有很大的帮助。
你可以通过 jstack
命令或者 Java VisualVM
这个可视化工具将JVM所有的线程栈信息导出来,完整的线程栈信息不仅包括线程的当前状态、调用栈,还包括了锁的信息。例如,我曾经写过一个死锁的程序,导出的线程栈明确告诉我发生了死锁,并且将死锁线程的调用栈信息清晰地显示出来了(如下图)。导出线程栈,分析线程状态是诊断并发问题的一个重要工具。
上面讲了线程的生命周期,紧接着我们聊下线程数应该怎样设置才合理?
java程序使用多线程还是比较简单的,但是使用多少个线程却是个困难的问题。工作中,经常有人问,“各种线程池的线程数量调整成多少是合适的?”或者“Tomcat的线程数、Jdbc连接池的连接数是多少?”等等。那我们应该如何设置合适的线程数呢?
要解决这个问题,首先要分析以下两个问题:
-
为什么要使用多线程? -
多线程的应用场景有哪些?
为什么要使用多线程?
使用多线程,本质上就是提升程序性能。不过此刻谈到的性能,可能在你脑海里还是比较笼统的,基本上就是快、快、快,这种无法度量的感性认识很不科学,所以在提升性能之前,首要问题是:如何度量性能。
度量性能的指标有很多,但是有两个指标是最核心的,它们就是延迟和吞吐量。 延迟 指的是发出请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也就越好。 吞吐量 指的是在单位时间内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。这两个指标内部有一定的联系(同等条件下,延迟越短,吞吐量越大),但是由于它们隶属不同的维度(一个是时间维度,一个是空间维度),并不能互相转换。
我们所谓提升性能,从度量的角度,主要是 降低延迟,提高吞吐量。这也是我们使用多线程的主要目的。那我们该怎么降低延迟,提高吞吐量呢?这个就要从多线程的应用场景说起了。
多线程的应用场景
要想“降低延迟,提高吞吐量”,对应的方法呢,基本上有两个方向,一个方向是 优化算法,另一个方向是 将硬件的性能发挥到极致。前者属于算法范畴,后者则是和并发编程息息相关了。那计算机主要有哪些硬件呢?主要是两类:一个是I/O,一个是CPU。简言之, 在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升I/O的利用率和CPU的利用率。
估计这个时候你会有个疑问,操作系统不是已经解决了硬件的利用率问题了吗?的确是这样,例如操作系统已经解决了磁盘和网卡的利用率问题,利用中断机制还能避免CPU轮询I/O状态,也提升了CPU的利用率。但是操作系统解决硬件利用率问题的对象往往是单一的硬件设备,而我们的并发程序,往往需要CPU和I/O设备相互配合工作,也就是说, 我们需要解决CPU和I/O设备综合利用率的问题。关于这个综合利用率的问题,操作系统虽然没有办法完美解决,但是却给我们提供了方案,那就是:多线程。
下面我们用一个简单的示例来说明:如何利用多线程来提升CPU和I/O设备的利用率?假设程序按照CPU计算和I/O操作交叉执行的方式运行,而且CPU计算和I/O操作的耗时是1:1。
如下图所示,如果只有一个线程,执行CPU计算的时候,I/O设备空闲;执行I/O操作的时候,CPU空闲,所以CPU的利用率和I/O设备的利用率都是50%。
单线程执行示意图
如果有两个线程,如下图所示,当线程A执行CPU计算的时候,线程B执行I/O操作;当线程A执行I/O操作的时候,线程B执行CPU计算,这样CPU的利用率和I/O设备的利用率就都达到了100%。
二线程执行示意图
我们将CPU的利用率和I/O设备的利用率都提升到了100%,会对性能产生了哪些影响呢?通过上面的图示,很容易看出:单位时间处理的请求数量翻了一番,也就是说吞吐量提高了1倍。此时可以逆向思维一下, 如果CPU和I/O设备的利用率都很低,那么可以尝试通过增加线程来提高吞吐量。
在单核时代,多线程主要就是用来平衡CPU和I/O设备的。如果程序只有CPU计算,而没有I/O操作的话,多线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。但是在多核时代,这种纯计算型的程序也可以利用多线程来提升性能。为什么呢?因为利用多核可以降低响应时间。
为便于你理解,这里我举个简单的例子说明一下:计算1+2+… … +100亿的值,如果在4核的CPU上利用4个线程执行,线程A计算[1,25亿),线程B计算[25亿,50亿),线程C计算[50,75亿),线程D计算[75亿,100亿],之后汇总,那么理论上应该比一个线程计算[1,100亿]快将近4倍,响应时间能够降到25%。一个线程,对于4核的CPU,CPU的利用率只有25%,而4个线程,则能够将CPU的利用率提高到100%。
多核执行多线程示意图
创建多少线程合适?
创建多少线程合适,要看多线程具体的应用场景。我们的程序一般都是CPU计算和I/O操作交叉执行的,由于I/O设备的速度相对于CPU来说都很慢,所以大部分情况下,I/O操作执行的时间相对于CPU计算来说都非常长,这种场景我们一般都称为I/O密集型计算;和I/O密集型计算相对的就是CPU密集型计算了,CPU密集型计算大部分场景下都是纯CPU计算。I/O密集型程序和CPU密集型程序,计算最佳线程数的方法是不同的。
下面我们对这两个场景分别说明。
对于CPU密集型计算,多线程本质上是提升多核CPU的利用率,所以对于一个4核的CPU,每个核一个线程,理论上创建4个线程就可以了,再多创建线程也只是增加线程切换的成本。所以, 对于CPU密集型的计算场景,理论上“线程的数量=CPU核数”就是最合适的。不过在工程上, 线程的数量一般会设置为“CPU核数+1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证CPU的利用率。
对于I/O密集型的计算场景,比如前面我们的例子中,如果CPU计算和I/O操作的耗时是1:1,那么2个线程是最合适的。如果CPU计算和I/O操作的耗时是1:2,那多少个线程合适呢?是3个线程,如下图所示:CPU在A、B、C三个线程之间切换,对于线程A,当CPU从B、C切换回来时,线程A正好执行完I/O操作。这样CPU和I/O设备的利用率都达到了100%。
三线程执行示意图
通过上面这个例子,我们会发现,对于I/O密集型计算场景,最佳的线程数是与程序中CPU计算和I/O操作的耗时比相关的,我们可以总结出这样一个公式:
最佳线程数=1 +(I/O耗时 / CPU耗时)
我们令R=I/O耗时 / CPU耗时,综合上图,可以这样理解:当线程A执行IO操作时,另外R个线程正好执行完各自的CPU计算。这样CPU的利用率就达到了100%。
不过上面这个公式是针对单核CPU的,至于多核CPU,也很简单,只需要等比扩大就可以了,计算公式如下:
最佳线程数=CPU核数 * [ 1 +(I/O耗时 / CPU耗时)]
很多人都知道线程数不是越多越好,但是设置多少是合适的,却又拿不定主意。其实只要把握住一条原则就可以了,这条原则就是 将硬件的性能发挥到极致。上面我们针对CPU密集型和I/O密集型计算场景都给出了理论上的最佳公式,这些公式背后的目标其实就是 将硬件的性能发挥到极致。
对于I/O密集型计算场景,I/O耗时和CPU耗时的比值是一个关键参数,不幸的是这个参数是未知的,而且是动态变化的,所以工程上,我们要估算这个参数,然后做各种不同场景下的压测来验证我们的估计。不过工程上,原则还是 将硬件的性能发挥到极致,所以压测时,我们需要重点关注CPU、I/O设备的利用率和性能指标(响应时间、吞吐量)之间的关系。
最后我们来谈一谈另外一个话题,为什么局部变量是安全的?
我们一遍一遍重复再重复地讲到,多个线程同时访问共享变量的时候,会导致并发问题。那在Java语言里,是不是所有变量都是共享变量呢?工作中我发现不少同学会给方法里面的局部变量设置同步,显然这些同学并没有把共享变量搞清楚。那Java方法里面的局部变量是否存在并发问题呢?下面我们就先结合一个例子剖析下这个问题。
比如,下面代码里的 fibonacci() 这个方法,会根据传入的参数 n ,返回 1 到 n 的斐波那契数列,斐波那契数列类似这样: 1、1、2、3、5、8、13、21、34……第1项和第2项是1,从第3项开始,每一项都等于前两项之和。在这个方法里面,有个局部变量:数组 r 用来保存数列的结果,每次计算完一项,都会更新数组 r 对应位置中的值。你可以思考这样一个问题,当多个线程调用 fibonacci() 这个方法的时候,数组 r 是否存在数据竞争(Data Race)呢?
// 返回斐波那契数列
int[] fibonacci(int n) {
// 创建结果数组
int[] r = new int[n];
// 初始化第一、第二个数
r[0] = r[1] = 1; // ①
// 计算2..n
for(int i = 2; i < n; i++) {
r[i] = r[i-2] + r[i-1];
}
return r;
}
你自己可以在大脑里模拟一下多个线程调用 fibonacci() 方法的情景,假设多个线程执行到 ① 处,多个线程都要对数组r的第1项和第2项赋值,这里看上去感觉是存在数据竞争的,不过感觉再次欺骗了你。
其实很多人也是知道局部变量不存在数据竞争的,但是至于原因嘛,就说不清楚了。
那它背后的原因到底是怎样的呢?要弄清楚这个,你需要一点编译原理的知识。你知道在CPU层面,是没有方法概念的,CPU的眼里,只有一条条的指令。编译程序,负责把高级语言里的方法转换成一条条的指令。所以你可以站在编译器实现者的角度来思考“怎么完成方法到指令的转换”。
方法是如何被执行的
高级语言里的普通语句,例如上面的 r[i] = r[i-2] + r[i-1];
翻译成CPU的指令相对简单,可方法的调用就比较复杂了。例如下面这三行代码:第1行,声明一个int变量a;第2行,调用方法 fibonacci(a);第3行,将b赋值给c。
int a = 7;
int[] b = fibonacci(a);
int[] c = b;
当你调用fibonacci(a)的时候,CPU要先找到方法 fibonacci() 的地址,然后跳转到这个地址去执行代码,最后CPU执行完方法 fibonacci() 之后,要能够返回。首先找到调用方法的下一条语句的地址:也就是 int[] c=b;
的地址,再跳转到这个地址去执行。 你可以参考下面这个图再加深一下理解。
方法的调用过程
到这里,方法调用的过程想必你已经清楚了,但是还有一个很重要的问题,“CPU去哪里找到调用方法的参数和返回地址?”如果你熟悉CPU的工作原理,你应该会立刻想到: 通过CPU的堆栈寄存器。CPU支持一种栈结构,栈你一定很熟悉了,就像手枪的弹夹,先入后出。因为这个栈是和方法调用相关的,因此经常被称为 调用栈。
例如,有三个方法A、B、C,他们的调用关系是A->B->C(A调用B,B调用C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为 栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说, 栈帧和方法是同生共死的。
调用栈结构
利用栈结构来支持方法调用这个方案非常普遍,以至于CPU里内置了栈寄存器。虽然各家编程语言定义的方法千奇百怪,但是方法的内部执行原理却是出奇的一致:都是 靠栈结构解决 的。Java语言虽然是靠虚拟机解释执行的,但是方法的调用也是利用栈结构解决的。
局部变量存哪里?
我们已经知道了方法间的调用在CPU眼里是怎么执行的,但还有一个关键问题:方法内的局部变量存哪里?
局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。此时你应该会想到调用栈的栈帧,调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。事实上,的确是这样的, 局部变量就是放到了调用栈里。于是调用栈的结构就变成了下图这样。
保护局部变量的调用栈结构
这个结论相信很多人都知道,因为学Java语言的时候,基本所有的教材都会告诉你 new 出来的对象是在堆里,局部变量是在栈里,只不过很多人并不清楚堆和栈的区别,以及为什么要区分堆和栈。现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。
调用栈与线程
两个线程可以同时用不同的参数调用相同的方法,那调用栈和线程之间是什么关系呢?答案是: 每个线程都有自己独立的调用栈。因为如果不是这样,那两个线程就互相干扰了。如下面这幅图所示,线程A、B、C每个线程都有自己独立的调用栈。
线程与调用栈的关系图
现在,我们回头看看刚刚的问题:Java方法里面的局部变量是否存在并发问题?现在你应该很清楚了,一点问题都没有。因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。再次重申一遍:没有共享,就没有伤害。
线程封闭
方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做 线程封闭,比较官方的解释是: 仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。
采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接Connection,在JDBC规范里并没有要求这个Connection必须是线程安全的。数据库连接池通过线程封闭技术,保证一个Connection一旦被一个线程获取之后,在这个线程关闭Connection之前的这段时间里,不会再分配给其他线程,从而保证了Connection不会有并发问题。
总结
今天我们围绕Java线程谈了Java线程的生命周期,从线程五态模型引出Java线程中的生命周期以及介绍了如何设置合理的线程数处理并发问题,最后我们分析了Java方法中局部变量的存储以及方法调用,比较偏基础,希望对你有帮助!
如果本文对你有帮助的话,欢迎点赞分享,这对我继续分享&创作优质文章非常重要。感谢 !
本文由 mdnice 多平台发布