总结
其他的内容都可以按照路线图里面整理出来的知识点逐一去熟悉,学习,消化,不建议你去看书学习,最好是多看一些视频,把不懂地方反复看,学习了一节视频内容第二天一定要去复习,并总结成思维导图,形成树状知识网络结构,方便日后复习。
这里还有一份很不错的《Java基础核心总结笔记》,特意跟大家分享出来
目录:
部分内容截图:
bool has_finalizer_flag = has_finalizer();
//返回实例大小
int size = size_helper();
//封装成KlassHandle句柄,可以简单理解为是Klass的封装类
KlassHandle h_k(THREAD, this);
instanceOop i;
//创建对象实例
i = (instanceOop)CollectedHeap::obj_allocate(h_k, size, CHECK_NULL);
//注册finalizer方法
if (has_finalizer_flag && !RegisterFinalizersAtInit) {
i = register_finalizer(i, CHECK_NULL);
}
return i;
}
复制
看到这里,小伙伴们可能会有点迷惑,搞不懂instanceOopDesc和InstanceKlass,这个地方涉及到jvm的oop-klass模型,我们来画张图简单说明下,通过这张图,我们就可以理解这两句话的原因:
1.加载.class到元空间时,会在堆中实例化出一个class对象
实际上是通过instanceMirrorKlass实例化出的
2.在java中利用反射传入的是java.lang.Class对象,创建出的是普通的java实例
因为java.lang.Class对象实际上是InstanceKlass的java镜像类,可以通过java.lang.Class对象获取InstanceKlass的信息,进而获取普通java实例的信息,从而创建普通实例
关于oop-klass模型也非常有意思,有兴趣的读者可以自行查看下源码,本篇还是着重讲述youngGC
言归正传,我们继续往下看:
oop CollectedHeap::obj_allocate(KlassHandle klass, int size, TRAPS) {
//申请内存并初始化对象
HeapWord* obj = common_mem_allocate_init(klass, size, CHECK_NULL);
post_allocation_setup_obj(klass, obj);
return (oop)obj;
}
HeapWord* CollectedHeap::common_mem_allocate_init(KlassHandle klass, size_t size, TRAPS) {
//先申请内存
HeapWord* obj = common_mem_allocate_noinit(klass, size, CHECK_NULL);
init_obj(obj, size);
return obj;
}
//最后进入这个方法
HeapWord* CollectedHeap::common_mem_allocate_noinit(KlassHandle klass, size_t size, TRAPS) {
HeapWord* result = NULL;
//是否使用TLAB处理,这里先不论述TLAB我们直接跳过
if (UseTLAB) {
result = allocate_from_tlab(klass, THREAD, size);
if (result != NULL) {
return result;
}
}
bool gc_overhead_limit_was_exceeded = false;
//堆中申请内存,直接进入这个方法
result = Universe::heap()->mem_allocate(size,
&gc_overhead_limit_was_exceeded);
…
}
Universe::heap()这个方法返回的是当前jvm使用的堆类,由于我们是G1的垃圾回收器,所以这里返回的是g1CollectedHeap.cpp:
HeapWord*
G1CollectedHeap::mem_allocate(size_t word_size,
bool* gc_overhead_limit_was_exceeded) {
//循环直到申请成功或者GC后申请失败
for (int try_count = 1, gclocker_retry_count = 0; /* we’ll return */; try_count += 1) {
unsigned int gc_count_before;
HeapWord* result = NULL;
//判断是否是大对象
if (!isHumongous(word_size)) {
//申请大对象的情况相对来说比较复杂,我们这里先看下申请小对象的逻辑
result = attempt_allocation(word_size, &gc_count_before, &gclocker_retry_count);
} else {
result = attempt_allocation_humongous(word_size, &gc_count_before, &gclocker_retry_count);
}
if (result != NULL) {
return result;
}
// 这个是fullGC的操作任务类
VM_G1CollectForAllocation op(gc_count_before, word_size);
//这里申请失败则会执行fullGC(这里先不论述FullGC)
VMThread::execute(&op);
…
}
return NULL;
}
大对象的申请流程比较复杂,笔者这里简单画了张图表示下大对象申请的流程,小伙伴们可以参考了解下,当然其中具体的步骤肯定不止这么简单,有兴趣的小伙伴可以自行查看源码
从小对象方法申请入手我们继续看:
inline HeapWord*
G1CollectedHeap::attempt_allocation(size_t word_size,
unsigned int* gc_count_before_ret,
int* gclocker_retry_count_ret) {
//再去申请一次内存,_mutator_alloc_region内部有指向当前活跃的eden region,每次申请会从这里申请内存
//在每次gc之前会清空申请的内存
HeapWord* result = _mutator_alloc_region.attempt_allocation(word_size,
false /* bot_updates */);
if (result == NULL) {
//失败后进入这个方法
result = attempt_allocation_slow(word_size,
gc_count_before_ret,
gclocker_retry_count_ret);
}
if (result != NULL) {
dirty_young_block(result, word_size);
}
return result;
}
//进入这个方法
HeapWord* G1CollectedHeap::attempt_allocation_slow(size_t word_size,
unsigned int *gc_count_before_ret,
int* gclocker_retry_count_ret) {
//这里默认情况下会循环三次,根据这个参数GCLockerRetryAllocationCount
HeapWord* result = NULL;
for (int try_count = 1; /* we’ll return */; try_count += 1) {
bool should_try_gc;
unsigned int gc_count_before;
{
MutexLockerEx x(Heap_lock);
//还是先去申请内存这个方法申请内存失败会将_mutator_alloc_region中的活跃区域进行retire并填充
//retire即将现在活跃的eden区region填充后加入到增量cset(即将要被回收的集合)中
//之后再去申请一块新的region代替当前活跃区域
//如果申请新的region失败才会继续下面操作进行GC
result = _mutator_alloc_region.attempt_allocation_locked(word_size,
false /* bot_updates */);
if (result != NULL) {
return result;
}
//GC_locker涉及到线程临界区的概念,这个方法会判断三个参数
//needs_gc && (_lock_count > 0 || _jni_lock_count > 0)
//这方法如果程序中使用jni开发可能会是true
if (GC_locker::is_active_and_needs_gc()) {
//判断是否可以扩容年轻代,可以则再申请一次
if (g1_policy()->can_expand_young_list()) {
result = _mutator_alloc_region.attempt_allocation_force(word_size,
false /* bot_updates */);
if (result != NULL) {
return result;
}
}
should_try_gc = false;
} else {
//这里判断need_gc,默认是fasle
if (GC_locker::needs_gc()) {
should_try_gc = false;
} else {
gc_count_before = total_collections();
should_try_gc = true;
}
}
}
//有且只有_lock_count ==0 && _jni_lock_count == 0 && !need_gc 这里是should_try_gc=true
if (should_try_gc) {
bool succeeded;
//执行GC
result = do_collection_pause(word_size, gc_count_before, &succeeded,
GCCause::_g1_inc_collection_pause);
if (result != NULL) {
return result;
}
if (succeeded) {
MutexLockerEx x(Heap_lock);
*gc_count_before_ret = total_collections();
return NULL;
}
} else {
//其他情况的GC_locker会走这里
//先判断循环次数超出则返回
if (*gclocker_retry_count_ret > GCLockerRetryAllocationCount) {
MutexLockerEx x(Heap_lock);
*gc_count_before_ret = total_collections();
return NULL;
}
//这个方法会被JNICritical_lock锁住直到needs_gc == false
// while (needs_gc()) {
// JNICritical_lock->wait();
// }
GC_locker::stall_until_clear();
(*gclocker_retry_count_ret) += 1;
}
//再去申请一次内存
result = _mutator_alloc_region.attempt_allocation(word_size,
false /* bot_updates */);
if (result != NULL) {
return result;
}
}
return NULL;
}
这里简单介绍下GC_locker这个类,这个类中有三个主要参数
**int **_lock_count : 记录jvm启动时的锁,在jvm启动前会+1,在jvm成功启动后会-1
**int **_jni_lock_count : 记录当前处于jni线程临界区的锁的数量,表示有几个线程处于临界区
**bool **needs_gc : 是否需要gc, 默认为false ,当有线程处于临界区时,此时如果需要gc,则会先舍弃gc,并且将needs_gc变为true,当所有线程处理完临界区后,根据这个标记再进行gc,之后再将其设置为false
这里涉及到一个新的名词:线程的临界区(即 critical region)表示存在对共享资源的多线程读写操作的代码块。
如果程序中使用了jni开发这里可能会使得_jni_lock_count>0且当前gc暂时舍去,如果没有使用则正常情况会继续gc。
二、youngGC全局停顿
=============
到这里youngGC的全局停顿就要开始了,我们直接来看代码
HeapWord* G1CollectedHeap::do_collection_pause(size_t word_size,
unsigned int gc_count_before,
bool* succeeded,
GCCause::Cause gc_cause) {
//记录gc停顿
//看到这里读者可能会比较疑惑,已经开始记录停顿开始了,但是怎么没有找到停顿的方法
//真正的停顿方法在VMThread::execute(&op);中
g1_policy()->record_stop_world_start();
// gc操作任务类,第三个参数表示本次gc是不是老年代并发gc
VM_G1IncCollectionPause op(gc_count_before,
word_size,
false, /* should_initiate_conc_mark */
g1_policy()->max_pause_time_ms(),
gc_cause);
VMThread::execute(&op);
HeapWord* result = op.result();
bool ret_succeeded = op.prologue_succeeded() && op.pause_succeeded();
*succeeded = ret_succeeded;
return result;
}
//简单介绍下VMThread,其实是原生的vm线程
void VMThread::execute(VM_Operation* op) {
Thread* t = Thread::current();
//判断当前线程是否是vm线程,这里t->is_VM_thread()返回true
if (!t->is_VM_thread()) {
//跳过这里,其实这里的逻辑是当前不是vm线程是java线程或者watcher线程
//会先将任务放到一个queue中,之后再执行
…
} else {
//如果是vm线程则会进入这里
…
HandleMark hm(t);
_cur_vm_operation = op;
//判断任务是否需要再安全点执行且当前是否在安全点
if (op->evaluate_at_safepoint() && !SafepointSynchronize::is_at_safepoint()) {
//如果不是安全点,则等待所有线程进入安全点,然后把线程暂时挂起
//这个类中有个状态 _state,所有java线程转换线程状态时会去判断这个状态然后
//决定是否block
SafepointSynchronize::begin();
//开始任务, op是刚刚传入的VM_G1IncCollectionPause操作任务类
//evaluate()方法最后会调用gc操作任务类的doit()方法
op->evaluate();
//安全点结束
SafepointSynchronize::end();
} else {
//是安全点则直接执行
op->evaluate();
}
if (op->is_cheap_allocated()) delete op;
_cur_vm_operation = prev_vm_operation;
}
}
SafepointSynchronize::begin()方法内包括了准备进入安全点到所有java线程Block的过程,此时youngGC的全局停顿开始了,之前和一些小伙伴沟通的时候发现有的小伙伴完全不知道G1的youngGC过程会发生全局停顿,导致程序出现不能提供服务的时候只盲目地去查fullGC日志,实际上通过源码我们可以直到G1的youngGC过程也会产生全局停顿,这是我们平时容易忽视的一点。
三、youngGC之前的准备工作
================
youngGC并不是直接就开始的,再着之前还会有很多准备工作,我们先来看看doit方法:
void VM_G1IncCollectionPause::doit() {
G1CollectedHeap* g1h = G1CollectedHeap::heap();
if (_word_size > 0) {
//会再去申请一次内存
_result = g1h->attempt_allocation_at_safepoint(_word_size,
false /* expect_null_cur_alloc_region */);
if (_result != NULL) {
_pause_succeeded = true;
return;
}
}
GCCauseSetter x(g1h, _gc_cause);
// youngGC这里是之前创建VM_G1IncCollectionPause的第三个参数:false
// 表示此次youngGC不属于老年代并发gc的周期
if (_should_initiate_conc_mark) {
_old_marking_cycles_completed_before = g1h->old_marking_cycles_completed();
bool res = g1h->g1_policy()->force_initial_mark_if_outside_cycle(_gc_cause);
if (!res) {
if (_gc_cause != GCCause::_g1_humongous_allocation) {
_should_retry_gc = true;
}
return;
}
}
//gc方法
_pause_succeeded =
g1h->do_collection_pause_at_safepoint(_target_pause_time_ms);
if (_pause_succeeded && _word_size > 0) {
// 再去申请内存
_result = g1h->attempt_allocation_at_safepoint(_word_size,
true /* expect_null_cur_alloc_region */);
} else {
if (!_pause_succeeded) {
_should_retry_gc = true;
}
}
}
继续往下看:
//这个方法是gc中最长的方法,我们忽略掉一些记日志和统计数据的方法,直接看关键的几个方法
bool
G1CollectedHeap::do_collection_pause_at_safepoint(double target_pause_time_ms) {
//判断是否有线程再临界区,如果有则舍弃本次gc,并把need_gc参数设置为true
//这里是我们刚刚提到的gc_locker
if (GC_locker::check_active_before_gc()) {
return false;
}
//打印一些heap日志和统计数据
…
//这个方法将会判断此次youngGC是不是一次初次标记
//(即老年代并发垃圾回收时,会伴随一次youngGC,此时会返回true)
//纯youngGC阶段这里会返回false
g1_policy()->decide_on_conc_mark_initiation();
//是否处于初始标记停顿阶段(youngGC这里返回的是false)
bool should_start_conc_mark = g1_policy()->during_initial_mark_pause();
{
EvacuationInfo evacuation_info;
//youngGC这里是false我们直接跳过
if (g1_policy()->during_initial_mark_pause()) {
increment_old_marking_cycles_started();
register_concurrent_cycle_start(_gc_timer_stw->gc_start());
}
//打印GC开始日志和活跃线程数
…
TraceCollectorStats tcs(g1mm()->incremental_collection_counters());
TraceMemoryManagerStats tms(false /* fullGC */, gc_cause());
//将二级空闲region列表合并到主空闲列表中
//二级空闲region列表包括之前释放的region
if (!G1StressConcRegionFreeing) {
append_secondary_free_list_if_not_empty_with_lock();
}
{
IsGCActiveMark x;
//填充tlab,扫描所有java线程的tlab并用空对象填充,之后再重置
gc_prologue(false);
…
//启动在全局停顿时期的软引用发现执行器
ref_processor_stw()->enable_discovery(true /verify_disabled/,
true /verify_no_refs/);
{
//关闭并发扫描阶段的软引用发现执行器
NoRefDiscovery no_cm_discovery(ref_processor_cm());
// 重置_mutator_alloc_region即主动将其中的活跃区域retire但是这次不会填充还没使用的区域
// 因为马上要gc了,之后将活跃的region并将其加入到_inc_cset_head中(增量cset)
// _mutator_alloc_region前面我们提到过是申请伊甸区内存的类
// 此时增量cset中将包含所有的eden区region
release_mutator_alloc_region();
…
// 完成cset,这个方法会将增量cset设置成cset,并将youngRegion中的survivor区全部设置成单纯的young区
// 因为gc之后老的survivor区将变成新的eden区
// 此时cset中将包含所有的eden区
g1_policy()->finalize_cset(target_pause_time_ms, evacuation_info);
…
// 初始化GC要申请的region,包括要用的新的survivor区域和old区域
init_gc_alloc_regions(evacuation_info);
// 执行gc
evacuate_collection_set(evacuation_info);
//这个方法我们先看到这个里,后面是gc的收尾工作,我们之后再看
…
}
这里的代码比较复杂,其中cset就是我们需要回收的region集合,在youngGC中它包括所有eden区,这是youngGC中需要释放回收的区域
我们先看下执行gc的方法evacuate_collection_set(evacuation_info):
方法比较长,在看之前我们先简单介绍下几个即将涉及到的知识点:
dirty card : 脏卡,在g1中每个region被分成若干个card,card用于映射一块内存块,其中不止有一个对象,当card有一个及以上对象的字段存在跨代引用时就被标记为脏
Rset : 记忆集合,在g1中用于解决跨region引用问题,在g1中只有老年代引用新生代的对象会被记录到Rset中,这样在youngGC中就可以避免扫描整个比较大的老年代减少开销,而只用处理记忆集合就好。在g1中rset只有脏卡会被加入到rset中
dirty card queue : 脏卡队列,再执行引用赋值语句时再写屏障中会将判断当前赋值是否跨代,如果跨代则将其对应的card标记为脏并加入dirty card queue中,后续会由其他线程异步处理更新到rset中。
dirty card queue set : 脏卡队列集合,其中由所有脏卡队列,当一个脏卡队列满时会在其中记录,youngGC时会将脏卡集合队列中满的脏卡队列更新到rset中。
这里涉及到的就是g1的写屏障和记忆集合,卡表等知识点,以后有机会笔者再专门介绍下相关知识,这里我们先简单了解下。
void G1CollectedHeap::evacuate_collection_set(EvacuationInfo& evacuation_info) {
//这个方法会把dcqs中没有满的dcq加入满的集合
//因为之后要更新脏卡到rset中,所以这里会把所有没满的集合标记成满的集合,之后只需要处理被标记为满的queue就可以了
g1_rem_set()->prepare_for_oops_into_collection_set_do();
// 先关闭热卡缓存
G1HotCardCache* hot_card_cache = _cg1r->hot_card_cache();
hot_card_cache->reset_hot_cache_claimed_index();
hot_card_cache->set_use_cache(false);
// 获取gc工作线程
uint n_workers;
if (G1CollectedHeap::use_parallel_gc_threads()) {
n_workers =
AdaptiveSizePolicy::calc_active_workers(workers()->total_workers(),
workers()->active_workers(),
Threads::number_of_non_daemon_threads());
workers()->set_active_workers(n_workers);
set_par_threads(n_workers);
} else {
n_workers = 1;
}
//youngGC任务类
G1ParTask g1_par_task(this, _task_queues);
init_for_evac_failure(NULL);
rem_set()->prepare_for_younger_refs_iterate(true);
…
{
StrongRootsScope srs(this);
//使用gc工作线程执行gc任务
if (G1CollectedHeap::use_parallel_gc_threads()) {
// The individual threads will set their evac-failure closures.
if (ParallelGCVerbose) G1ParScanThreadState::print_termination_stats_hdr();
// These tasks use ShareHeap::_process_strong_tasks
workers()->run_task(&g1_par_task);
} else {
g1_par_task.set_for_termination(n_workers);
g1_par_task.work(0);
}
end_par_time_sec = os::elapsedTime();
}
…
set_par_threads(0);
//在之前我们开启了软引用执行器,这里会处理软引用
process_discovered_references(n_workers);
//处理弱引用
{
G1STWIsAliveClosure is_alive(this);
G1KeepAliveClosure keep_alive(this);
JNIHandles::weak_oops_do(&is_alive, &keep_alive);
}
release_gc_alloc_regions(n_workers, evacuation_info);
g1_rem_set()->cleanup_after_oops_into_collection_set_do();
//重新启动热卡缓存
hot_card_cache->reset_hot_cache();
hot_card_cache->set_use_cache(true);
…
}
三、扫描根节点
=======
下面我们终于可以看到我们所熟知的扫描根节点的方法,网上许多文章都是从这一步开始的,但其实youngGC在这之前做的准备还是很多的,而且这些准备都是包括在停顿范围内的,所以youngGC的停顿时间不止是从扫描根节点开始(虽然大部分时候准备时间可以忽略不计),这些只有亲自看了源码才能理解到。
//G1ParTask的work方法,youngGC入口
void work(uint worker_id) {
…
{
ResourceMark rm;
HandleMark hm;
//这边声明了许多闭包
ReferenceProcessor* rp = _g1h->ref_processor_stw();
//线程扫描状态闭包,里面有一个rset引用queue用于作为rset的缓冲
//这里我们先关注下这个pss后面更新rset时会用到
G1ParScanThreadState pss(_g1h, worker_id);
G1ParScanHeapEvacClosure scan_evac_cl(_g1h, &pss, rp);
G1ParScanHeapEvacFailureClosure evac_failure_cl(_g1h, &pss, rp);
G1ParScanPartialArrayClosure partial_scan_cl(_g1h, &pss, rp);
//这里会把扫描闭包放入pss
pss.set_evac_closure(&scan_evac_cl);
pss.set_evac_failure_closure(&evac_failure_cl);
pss.set_partial_scan_closure(&partial_scan_cl);
//这些都是别名实际上是 G1ParCopyClosure迭代器
//只扫描根
//扫描根的迭代器纯youngGC中使用的时这个
G1ParScanExtRootClosure only_scan_root_cl(_g1h, &pss, rp);
//只扫描元数据
G1ParScanMetadataClosure only_scan_metadata_cl(_g1h, &pss, rp);
…
//扫描根的迭代器
OopClosure* scan_root_cl = &only_scan_root_cl;
G1KlassScanClosure* scan_klasses_cl = &only_scan_klasses_cl_s;
…
//用来将rset中的card推入queue中后面统一copy对象的闭包
//pss中有处理rset中引用的queue
G1ParPushHeapRSClosure push_heap_rs_cl(_g1h, &pss);
int so = SharedHeap::SO_AllClasses | SharedHeap::SO_Strings;
//扫描根节点,第三个参数是G1ParScanExtRootClosure是主要的迭代器
//第四个参数是G1ParScanThreadState
_g1h->g1_process_strong_roots(/* is scavenging */ true,
SharedHeap::ScanningOption(so),
scan_root_cl,
&push_heap_rs_cl,
scan_klasses_cl,
worker_id);
{
double start = os::elapsedTime();
//pss中有处理rset中引用的queue
G1ParEvacuateFollowersClosure evac(_g1h, &pss, _queues, &_terminator);
//更新rset,这个方法我们注意下后面会讲到
evac.do_void();
…
}
…
}
…
}
};
void
G1CollectedHeap::
g1_process_strong_roots(bool is_scavenging,
ScanningOption so,
OopClosure* scan_non_heap_roots,
OopsInHeapRegionClosure* scan_rs,
G1KlassScanClosure* scan_klasses,
int worker_i) {
//根迭代器
//缓冲迭代器,传入的即上面提到G1ParScanExtRootClosure
//缓冲迭代器即是将迭代任务缓冲起来分批执行即将扫描gcRoot的任务缓冲到里面执行
BufferingOopClosure buf_scan_non_heap_roots(scan_non_heap_roots);
CodeBlobToOopClosure eager_scan_code_roots(scan_non_heap_roots, true /* do_marking */);
//扫描根的方法,这个方法会将gcRoot分别进行扫描,我们就不展开看了
//我们看下刚刚提到的迭代器G1ParScanExtRootClosure,这个是主要处理gcRoot的方法
process_strong_roots(false, // no scoping; this is parallel code
is_scavenging, so,
&buf_scan_non_heap_roots,
&eager_scan_code_roots,
scan_klasses
);
// 等待缓冲迭代器执行完毕,此时GCRoot已经扫描完毕
buf_scan_non_heap_roots.done();
//省略一些记录时间的代码
…
最后
针对最近很多人都在面试,我这边也整理了相当多的面试专题资料,也有其他大厂的面经。希望可以帮助到大家。
最新整理面试题
上述的面试题答案都整理成文档笔记。也还整理了一些面试资料&最新2021收集的一些大厂的面试真题
最新整理电子书
最新整理大厂面试文档
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。
InHeapRegionClosure* scan_rs,
G1KlassScanClosure* scan_klasses,
int worker_i) {
//根迭代器
//缓冲迭代器,传入的即上面提到G1ParScanExtRootClosure
//缓冲迭代器即是将迭代任务缓冲起来分批执行即将扫描gcRoot的任务缓冲到里面执行
BufferingOopClosure buf_scan_non_heap_roots(scan_non_heap_roots);
CodeBlobToOopClosure eager_scan_code_roots(scan_non_heap_roots, true /* do_marking */);
//扫描根的方法,这个方法会将gcRoot分别进行扫描,我们就不展开看了
//我们看下刚刚提到的迭代器G1ParScanExtRootClosure,这个是主要处理gcRoot的方法
process_strong_roots(false, // no scoping; this is parallel code
is_scavenging, so,
&buf_scan_non_heap_roots,
&eager_scan_code_roots,
scan_klasses
);
// 等待缓冲迭代器执行完毕,此时GCRoot已经扫描完毕
buf_scan_non_heap_roots.done();
//省略一些记录时间的代码
…
最后
针对最近很多人都在面试,我这边也整理了相当多的面试专题资料,也有其他大厂的面经。希望可以帮助到大家。
最新整理面试题
[外链图片转存中…(img-2KJ8icjW-1715267730744)]
上述的面试题答案都整理成文档笔记。也还整理了一些面试资料&最新2021收集的一些大厂的面试真题
最新整理电子书
[外链图片转存中…(img-txsklrta-1715267730745)]
最新整理大厂面试文档
[外链图片转存中…(img-nz9NSiJs-1715267730745)]
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。