每日一博 | 深入分析 OpenJDK G1 FullGC 原理
导读
本文主要从代码层面介绍 OpenJDK G1(garbage first) FullGC 的工作原理,基于 OpenJDK 从 GC(garbage collection) 入口处开始分析整个 FullGC 原理的核心代码与执行过程;并对比 OpenJDK8 与 OpenJDK11 中单线程与多线程实现 FullGC 的异同。希望帮助大家更深入地理解 G1 FullGC 原理,并了解代码中可能导致 GC 时间长的对象分配方式。
阅读本文需要对 JVM、JDK、java对象、对象内存分配、垃圾回收等概念有一定了解,没有 JVM 基础的同学可参考《深入理解Java虚拟机》,《The Garbage Collection Handbook》等。本文涉及源码可参考openjdk github source code
G1 GC的知识回顾
为了帮助大家更全面地理解 FullGC 的逻辑和前因后果,我们先整体简单介绍下 G1 GC 。GC 主要指在程序运行过程中,对象不再使用后,由 JVM 提供的程序将对象销毁并回收内存,将该内存再加入到可用内存中;而 G1GC,是 JVM 提供的一种垃圾回收器,可以通过多 worker 并行回收,并且通过 CMS 并发标记,此外通过 region 的分代回收,可以较好的控制单次回收的暂停时间,并且有效的抑制碎片化的问题。但 G1 并不是完美的回收器,相比于 CMS GC,G1GC CPU 负载相对较高;吞吐量在小内存上可能相对较弱,但 G1GC 整体在回收时间和效率上都表现较为优秀,是当前 java 业务中使用较为频繁的垃圾回收器。但随着使用场景的变换,为了在业务中更好地完成垃圾回收调优,可以更多地了解下这项技术的基础知识。
G1 将整个 JVM 运行时堆分为若干个 region,region 大小在运行之初设定,且一经设定不可改变;每个 region 都可能被作以下分别:包括 Eden,Survivor,Old,Humongous 和 Free 等区域,其中 Eden region 指年轻代,一般对象(大小小于 region 的一半)对象分配的默认选择区域,并且为每次内存空间不足时的优先回收区;从一次垃圾回收存活下来的对象会被 copy 到 survivor region,经过几轮回收后,依然存活的对象太多,则这些对象会被移动到 Old region;当对象大小大于region 一半会被直接分配到 Humongous region,只有在 Full GC 或者 CMS 后才有机会回收。
回收包含 4 部分 YoungGC(Pause)、MixGC、Concurrent Marking 和 FullGC
YoungGC:选择全部年轻代(Eden),根据暂停时间要求决定回收年轻代的数量。
- stop the world(stw)暂停所有应用线程,G1 创建回收集 Collection Set(cset)
- 扫描 root,从栈、classloader、JNIHandle 等出发,扫描标记直接引用的对象
- 基于要回收的 region 的 rset 来扫描标记对应 region 的存活对象
- 然后将活对象 copy 到新 region
- 处理其他引用
Concurrent Marking Cycle: JVM 内存使用比例超过一定阈值,会触发全局并发标记,包括 clean up 阶段(这里会清理出部分大对象)
MixGC:选择全部年轻代和部分老年代,会按照垃圾占比高低来排序,优先回收垃圾多的 set,G1也得名于此(Garbage first),回收过程同 YoungGC 一样。
FullGC: MixGC 来不及回收或回收不掉,分配内存仍失败,就会触发全局回收,包括所有年轻代、老年代,大对象以及少量其他的 JVM 管理的内存,过程总体同 YoungGC 一样。不过扫描 root 过程会将整个堆完全扫描完。对于有些高流量的业务程序而言,适当的 FullGC 可以更好的调整整个内存,但一般情况而言,因为回收暂停时间较长,我们应当尽量避免 FullGC。
G1 FullGC 回收流程图简图如下
G1 FullGC入口(触发条件)
触发 FullGC 的条件主要分两大类,第一类是在对象分配时发现内存不足导致分配失败,会触发回收逻辑,在 young gc/mix gc 尝试回收后,都不能释放足够内存时,便会触发 FullGC;第二类为业务代码中主动调用回收内存函数,比如 System.gc。两类最终都会调用到 do_collection(),此处不涉及 parallel,OpenJDK8 和 OpenJDK11 入口相同
// 各种分配失败时的调用入口 G1CollectedHeap::mem_allocate VM_G1CollectForAllocation::doit() G1CollectedHeap::satisfy_failed_allocation() G1CollectedHeap::do_collection() // System.gc()和 metadata回收 G1CollectedHeap::collect(GCCause::Cause cause) VM_G1CollectFull::doit() G1CollectedHeap::do_full_collection() G1CollectedHeap::do_collection()
G1 FullGC回收前准备——prepare_collection
从 do_collection 调入后,对于 OpenJDK8,以 G1MarkSweep::invoke_at_safepoint 函数为边界,该函数前为回收准备,之后为收尾工作;而 OpenJDK11 则将整个 prepare_collection、collect 和 complete 三步都用 g1FullCollector 类进行封装,逻辑更加清晰。
准备过程两者相似,可简单分为三类
- 数据信息检查及记录:包括计时器,trace、safepoint 检查、日志打印等数据处理部分
- concurrent_mark 相关操作和暂停:包括 concurrent_mark 的 abort,cm discovery 禁用等
- 回收准备:heap 集合整理清除、policy 策略更新、开启 stw discovery、处理对象偏向锁等
// openjdk11 void G1FullCollector::prepare_collection() { _heap->g1_policy()->record_full_collection_start(); _heap->print_heap_before_gc(); _heap->print_heap_regions(); // 终止并发标记,禁用cm discovery _heap->abort_concurrent_cycle(); _heap->verify_before_full_collection(scope()->is_explicit_gc()); // 预处理当前堆 _heap->gc_prologue(true); _heap->prepare_heap_for_full_collection(); ... // 保存偏向锁的markword并进行初始标记 BiasedLocking::preserve_marks(); clear_and_activate_derived_pointers(); } void BiasedLocking::preserve_marks() { ... Thread* cur = Thread::current(); for (JavaThread* thread = Threads::first(); thread != NULL; thread = thread->next()) { if (thread->has_last_Java_frame()) { RegisterMap rm(thread); for (javaVFrame* vf = thread->last_java_vframe(&rm); vf != NULL; vf = vf->java_sender()) { GrowableArray<MonitorInfo*> *monitors = vf->monitors(); if (monitors != NULL) { int len = monitors->length(); // Walk monitors youngest to oldest for (int i = len - 1; i >= 0; i--) { MonitorInfo* mon_info = monitors->at(i); if (mon_info->owner_is_scalar_replaced()) continue; oop owner = mon_info->owner(); if (owner != NULL) { markOop mark = owner->mark(); if (mark->has_bias_pattern()) { _preserved_oop_stack->push(Handle(cur, owner)); _preserved_mark_stack->push(mark); } } } } } } } }
上述代码为垃圾回收前准备过程的封装,OpenJDK8和OpenJDK11整体过程基本一致(可进入调用函数与 OpenJDK8 对照,此处不展开),区别在于 OpenJDK11 会在 g1FullCollector 初始化过程中,根据 parallel 的使用情况对 preserve_stack、oop_stack、arrayOop_stack、compaction_points 等数据结构按照并行的线程数进行切分,初始化并用数组保存,为 parallel 回收做准备,服务于并行 FullGC。
// openjdk11 G1FullCollector::G1FullCollector(G1CollectedHeap* heap, GCMemoryManager* memory_manager, bool explicit_gc, bool clear_soft_refs) : _heap(heap), _scope(memory_manager, explicit_gc, clear_soft_refs), _num_workers(calc_active_workers()), _oop_queue_set(_num_workers), _array_queue_set(_num_workers), _preserved_marks_set(true), _serial_compaction_point(), _is_alive(heap->concurrent_mark()->nextMarkBitMap()), _is_alive_mutator(heap->ref_processor_stw(), &_is_alive), _always_subject_to_discovery() { assert(SafepointSynchronize::is_at_safepoint(), "must be at a safepoint"); _preserved_marks_set.init(_num_workers); _markers = NEW_C_HEAP_ARRAY(G1FullGCMarker*, _num_workers, mtGC); _compaction_points = NEW_C_HEAP_ARRAY(G1FullGCCompactionPoint*, _num_workers, mtGC); for (uint i = 0; i < _num_workers; i++) { _markers[i] = new G1FullGCMarker(i, _preserved_marks_set.get(i), mark_bitmap()); _compaction_points[i] = new G1FullGCCompactionPoint(); _oop_queue_set.register_queue(i, marker(i)->oop_stack()); _array_queue_set.register_queue(i, marker(i)->objarray_stack()); } }
G1 FullGC回收核心逻辑——collect
回收核心过程基本分为4步如下,包括标记阶段(phase1_mark_live_objects),复制和回收准备阶段(phase2_prepare_compaction),调整指针阶段(phase3_adjust_pointers)和压缩清理阶段(phase4_do_compaction):
// openjdk11 void G1FullCollector::collect() { phase1_mark_live_objects(); phase2_prepare_compaction(); phase3_adjust_pointers(); phase4_do_compaction(); }
OpenJDK11 中,这4步骤被封装成 4个 task ,继承自 AbstractGangTask,都保存有 monitor 和可执行的 GangWorker,利用 run_task 来根据 worker 数目分多线程执行 task,而执行逻辑封装在 task 的 work 函数中,每个 task 中会传入 worker_id,操作时根据 worker_id 来利用 G1FullCollector 的数据结构来进行操作。
void G1FullCollector::phase1_mark_live_objects() { GCTraceTime tm("Phase 1: Mark live objects", G1Log::fine(), true, scope()->timer(), scope()->tracer()->gc_id()); // Do the actual marking. G1FullGCMarkTask marking_task(this); run_task(&marking_task); ... } void G1FullCollector::run_task(AbstractGangTask* task) { ... _heap->workers()->run_task(task); ... }
1. phase1_mark_live_objects
以 marker 过程为例,第 5 行根据 worker_id 取到 collector 中准备好的数据结构,通过root_processor 来来执行三个闭包函数,最后会一路调用到每个 OOP 的 mark_and_push,对整个堆进行标记,此过程与 OpenJDK8 基本一致。
// openjdk11 void G1FullGCMarkTask::work(uint worker_id) { Ticks start = Ticks::now(); ResourceMark rm; G1FullGCMarker* marker = collector()->marker(worker_id); MarkingCodeBlobClosure code_closure(marker->mark_closure(), !CodeBlobToOopClosure::FixRelocations); if (ClassUnloading) { _root_processor.process_strong_roots( marker->mark_closure(), marker->cld_closure(), &code_closure); } else { _root_processor.process_all_roots_no_string_table( marker->mark_closure(), marker->cld_closure(), &code_closure); } // Mark stack is populated, now process and drain it. marker->complete_marking(collector()->oop_queue_set(), collector()->array_queue_set(), &_terminator); // This is the point where the entire marking should have completed. assert(marker->oop_stack()->is_empty(), "Marking should have completed"); assert(marker->objarray_stack()->is_empty(), "Array marking should have completed"); } void G1RootProcessor::process_strong_roots(OopClosure* oops, CLDClosure* clds, CodeBlobClosure* blobs) { process_java_roots(oops, clds, clds, NULL, blobs, NULL, 0); process_vm_roots(oops, NULL, NULL, 0); _process_strong_tasks.all_tasks_completed(); } void G1RootProcessor::process_vm_roots(OopClosure* strong_roots, OopClosure* weak_roots, G1GCPhaseTimes* phase_times, uint worker_i) { { G1GCParPhaseTimesTracker x(phase_times, G1GCPhaseTimes::UniverseRoots, worker_i); if (!_process_strong_tasks.is_task_claimed(G1RP_PS_Universe_oops_do)) { Universe::oops_do(strong_roots); } } // 各类可能的roots ... } bool SubTasksDone::is_task_claimed(uint t) { assert(0 <= t && t < _n_tasks, "bad task id."); uint old = _tasks[t]; if (old == 0) { old = Atomic::cmpxchg(1, &_tasks[t], 0); } bool res = old != 0; ... return res; } template <class T> inline void G1FullGCMarker::mark_and_push(T* p) { T heap_oop = oopDesc::load_heap_oop(p); if (!oopDesc::is_null(heap_oop)) { oop obj = oopDesc::decode_heap_oop_not_null(heap_oop); if (mark_object(obj)) { _oop_stack.push(obj); assert(_bitmap->isMarked((HeapWord*) obj), "Must be marked now - map self"); } else { assert(_bitmap->isMarked((HeapWord*) obj), "Must be marked by other"); } } }
补充多线程实现思路
以 mark 过程为例,简单说明下多线程回收思路。
1)首先在 java 程序执行时,可以通过添加 -XX:ParallelGCThreads=8 指定具体回收时的线程数;在启动后,SharedHeap 对象的初始化时,会调用 WorkGang::initialize_workers(),初始化 8 个 GC 线程(一般是以 GangWorker 实现,继承自 WorkerThread,也被称为工人线程)。随后线程开始执行会调用到 GangWorker 的 run 函数,调到 loop,进入 wait 阶段等待唤醒来执行任务,这个循环等待会持续整个 java 程序运行过程。
void GangWorker::run() { initialize(); loop(); } void GangWorker::loop() { ... Monitor* gang_monitor = gang()->monitor(); for ( ; /* !terminate() */; ) { WorkData data; int part; // Initialized below. { MutexLocker ml(gang_monitor); gang()->internal_worker_poll(&data); for ( ; /* break or return */; ) { // Check for new work. if ((data.task() != NULL) && (data.sequence_number() != previous_sequence_number)) { if (gang()->needs_more_workers()) { gang()->internal_note_start(); gang_monitor->notify_all(); part = gang()->started_workers() - 1; break; } } // Nothing to do. gang_monitor->wait(/* no_safepoint_check */ true); gang()->internal_worker_poll(&data); ... data.task()->work(part); ... } } } }
2)所有的 XXXTask 继承自 AbstractGangTask,会实现 work 方法;在回收过程执行时,会将待执行的 task 传给 heap 的 workers 来执行,即会调用到 Sharedheap 中提前保存好的 FlexibleWorkGang 的 run_task。而 FlexibleWorkGang 继承自 WorkGang,run_task 方法实现如下,会唤醒所有GC线程,并等待所有线程执行结束后返回。
// workgroup.cpp void FlexibleWorkGang::run_task(AbstractGangTask* task) { WorkGang::run_task(task, (uint) active_workers()); } void WorkGang::run_task(AbstractGangTask* task, uint no_of_parallel_workers) { task->set_for_termination(no_of_parallel_workers); MutexLockerEx ml(monitor(), Mutex::_no_safepoint_check_flag); ... monitor ()-> notify_all (); // Wait for them to be finished while (finished_workers() < no_of_parallel_workers) { if (TraceWorkGang) { tty->print_cr("Waiting in work gang %s: %d/%d finished sequence %d", name(), finished_workers(), no_of_parallel_workers, _sequence_number); } monitor()->wait(/* no_safepoint_check */ true); } ... }
3)task 开始执行后,会通过 processor 执行器执行到每个 task 的 work 方法,并根据执行线程传入worker_id,比如 G1FullGCMarkTask 的 _root_processor.process_strong_roots 函数,调用关系的其中一条分支如下,每一个 workgang 都会通过 ALL_JAVA_THREADS 来顺序遍历所有的 java 线程来执行 mark 操作,若已经执行过或正在执行就跳过,保证执行完所有线程,包括一个 VMThread,其中 mark 过程传入的 OopClosure、CLDClosure 为对应 worker_id 的 mark_closure、cld_closure,执行时会调用每个 marker 准备好的 oopStack 等数据结构来执行,都执行结束后所有的 GangWorker继续进入等待任务的过程中,直到下一个 run_task。
// process_strong_roots // -> process_java_roots // --> Threads::possibly_parallel_oops_do void Threads::possibly_parallel_oops_do(OopClosure* f, CLDClosure* cld_f, CodeBlobClosure* cf) { SharedHeap* sh = SharedHeap::heap(); bool is_par = sh->n_par_threads() > 0; assert(!is_par || (SharedHeap::heap()->n_par_threads() == SharedHeap::heap()->workers()->active_workers()), "Mismatch"); int cp = SharedHeap::heap()->strong_roots_parity(); ALL_JAVA_THREADS(p) { if (p->claim_oops_do(is_par, cp)) { p->oops_do(f, cld_f, cf); } } VMThread* vmt = VMThread::vm_thread(); if (vmt->claim_oops_do(is_par, cp)) { vmt->oops_do(f, cld_f, cf); } }
2. phase2_prepare_compaction
标记过后,准备压缩清理,根据标记情况计算地址,详细逻辑在 G1CalculatePointersClosure 实现如下;根据标记结果,通过 doHeapRegion 遍历含有存活对象的 region,并将存活对象复制到新的 region 内,copy 同时将对象头指向旧对象,为调整指针和清理旧 region 做准备。
// openjdk11 void G1FullGCPrepareTask::work(uint worker_id) { ... G1FullGCCompactionPoint* compaction_point = collector()->compaction_point(worker_id); G1CalculatePointersClosure closure(collector()->mark_bitmap(), compaction_point); G1CollectedHeap::heap()->heap_region_par_iterate_from_start(&closure, &_hrclaimer); ... } void G1CollectedHeap::heap_region_par_iterate_from_start(HeapRegionClosure* cl, HeapRegionClaimer *hrclaimer) const { _hrm.par_iterate(cl, hrclaimer, 0); } void HeapRegionManager::par_iterate(HeapRegionClosure* blk, HeapRegionClaimer* hrclaimer, const uint start_index) const { const uint n_regions = hrclaimer->n_regions(); for (uint count = 0; count < n_regions; count++) { const uint index = (start_index + count) % n_regions; ... HeapRegion* r = _regions.get_by_index(index); if (hrclaimer->is_region_claimed(index)) { continue; } if (!hrclaimer->claim_region(index)) { continue; } bool res = blk->do_heap_region(r); if (res) { return; } } } bool G1FullGCPrepareTask::G1CalculatePointersClosure::doHeapRegion(HeapRegion* hr) { if (hr->isHumongous()) { oop obj = oop(hr->humongous_start_region()->bottom()); if (_bitmap->isMarked((HeapWord*) obj)) { if (hr->startsHumongous()) { obj->forward_to(obj); } } else { free_humongous_region(hr); } } else if (!hr->isHumongous()) { prepare_for_compaction(hr); } reset_region_metadata(hr); return false; }
3. phase3_adjust_pointers
此步骤和phase2类似,遍历存活对象region,通过对象头记录的地址,将 live 的对象引用指向重新计算的地址。由 phase2 和 phase3 两个步骤可见,同样大小的内存占用,在 GC 过程中,存活对象数量越多会引起更多的复制和调整指针工作,导致更长的总回收时间。
// openjdk11 void G1FullGCAdjustTask::work(uint worker_id) { ... // Needs to be last, process_all_roots calls all_tasks_completed(...). _root_processor.process_all_roots( &_adjust, &adjust_cld, &adjust_code); ... // 在此步骤前adjust掉weak,preserved string等等, // 此步骤adjust pointers region by region G1AdjustRegionClosure blk(collector()->mark_bitmap(), worker_id); G1CollectedHeap::heap()->heap_region_par_iterate_from_worker_offset(&blk, &_hrclaimer, worker_id); log_task("Adjust task", worker_id, start); }
4. phase4_do_compaction
最后逐个 region 进行处理,若没有存活对象,清理掉 region;若有存活对象,则将对象按之前计算copy 到新地址,将旧 region 重置。
// openjdk11 void G1FullGCCompactTask::work(uint worker_id) { Ticks start = Ticks::now(); GrowableArray<HeapRegion*>* compaction_queue = collector()->compaction_point(worker_id)->regions(); for (GrowableArrayIterator<HeapRegion*> it = compaction_queue->begin(); it != compaction_queue->end(); ++it) { compact_region(*it); } G1ResetHumongousClosure hc(collector()->mark_bitmap()); G1CollectedHeap::heap()->heap_region_par_iterate_from_worker_offset(&hc, &_claimer, worker_id); ... } void G1FullGCCompactTask::compact_region(HeapRegion* hr) { ... G1CompactRegionClosure compact(collector()->mark_bitmap()); hr->apply_to_marked_objects(collector()->mark_bitmap(), &compact); if (!hr->is_empty()) { MemRegion mr(hr->bottom(), hr->top()); collector()->mark_bitmap()->clearRange(mr); } hr->complete_compaction(); } size_t G1FullGCCompactTask::G1CompactRegionClosure::apply(oop obj) { size_t size = obj->size(); HeapWord* destination = (HeapWord*)obj->forwardee(); if (destination == NULL) { return size; } HeapWord* obj_addr = (HeapWord*) obj; assert(obj_addr != destination, "everything in this pass should be moving"); Copy::aligned_conjoint_words(obj_addr, destination, size); oop(destination)->init_mark(); assert(oop(destination)->klass() != NULL, "should have a class"); return size; } inline void HeapRegion::complete_compaction() { ... if (used_region().is_empty()) { reset_bot(); } ... if (ZapUnusedHeapArea) { SpaceMangler::mangle_region(MemRegion(top(), end())); } }
G1 FullGC回收收尾——complete
在整个回收结束前,需要进行一些收尾工作,此处并无 parallel 区分。在 prepare_heap_for_mutators中,会包括重建 region 集合,将空的 region 加入 free_list;为每个 region 重建 strong root 列表;删除已卸载类加载器的元空间,并清理 loader_data 图;准备 collection set;重新处理卡表信息以及一些验证和信息打印等。
// openjdk11 void G1FullCollector::complete_collection() { 。。。 restore_marks(); BiasedLocking::restore_marks(); CodeCache::gc_epilogue(); JvmtiExport::gc_epilogue(); _heap->prepare_heap_for_mutators(); ... }
总结
相比于 YoungGC 和 Concurrent Marking Cycle 等,G1 回收器中 FullGC 过程更为独立、完整,但涉及面也更广,涉及到 JVM 对整个堆的管理细节,包括 OOP 对象结构,region 块,bitmap 标记管理,起始 root 管理,甚至GC线程后台运行情况,多线程同步,stw机制等等,每一部分的设计都很巧妙,值得我们深入代码中一探究竟,相信大家一定会有所收获。

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
全球首个运行于 RISC-V 平台的 Windows x64 软件模拟器发布
在最新的 Wine-CE 8.2 更新中,首次添加了针对RISC-V的支持,因此Wine-CE也成为全球首个运行于RISC-V平台的Windows x64软件模拟器。 近些年来,在半导体产业受到地缘政治影响的背景下,以开放性著称的RISC-V芯片指令集架构的影响力与日俱增。RISC-V指令集拥有基于开源、开放、共建、共治模式发展的全球生态,相较于其它指令集而言,其可以不受某个商业或政治实体控制,且无须支付授权费即可使用。因此,其被主导自主可控领域的企业和中小型企业所青睐。 然而,作为一款新兴的指令集,RISC-V的缺陷也显而易见,尤其是软件生态。即使由于上述优势,相关企业可以大幅降低生产过程中的风险和成本,但倘若没有足量可供运行的软件,那么实现RISC-V指令集崛起的梦想也若水中之月,可望而不可即。另一方面,在现代信息技术产业中,软件的开发成本远胜于硬件采购成本。且软件产业在全球化分工合作的背景下,单个软件的指令架构移植工作往往要涉及多个商业实体间的沟通合作,尤其是在RISC-V在市场占有率还不具备优势的前提下,其本不良好的私有软件生态只会雪上加霜。为了打破低硬件市场占有率导致低软件市...
- 下一篇
BlockSuite —— 协作编辑器
BlockSuite 是一个多人协作编辑器,支持多用户同时编辑一份文件,并且可以即时看到对方的编辑。BlockSuite 基于 local-first 理念,底层使用了 CRDT 库 yjs,还支持嵌入到任意不同框架 (React 、Vue 、Angular)。 BlockSuite 的工作方式不同于传统的富文本编辑器: 在数据模型上,BlockSuite 没有实现事件溯源模式 (event sourcing),而是提供了一个直接基于Yjs 的 block 树(底层采用 CRDT),支持开箱即用。它的数据持久层也被设计成本地优先 (local-first)。 对于富文本编辑,BlockSuite block 树中的多个不同节点可以连接到不同的富文本编辑组件,从而将富文本内容建模为多个 UI 组件而不是单个 UI 容器,从而消除了危险的单体应用contenteditale。 对于渲染层,BlockSuite 并不假设内容只能通过 DOM 来渲染。它不仅实现了基于Web Components的基本文档编辑 UI ,还为部分白板内容开发了基于 Canvas 的混合渲染器。两个渲染器可以共存于...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- 2048小游戏-低调大师作品
- Windows10,CentOS7,CentOS8安装MongoDB4.0.16
- SpringBoot2全家桶,快速入门学习开发网站教程
- Springboot2将连接池hikari替换为druid,体验最强大的数据库连接池
- Eclipse初始化配置,告别卡顿、闪退、编译时间过长
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- SpringBoot2更换Tomcat为Jetty,小型站点的福音
- CentOS8,CentOS7,CentOS6编译安装Redis5.0.7
- CentOS7,8上快速安装Gitea,搭建Git服务器
- CentOS6,CentOS7官方镜像安装Oracle11G