<>jvm优化的是什么

其实无论什么垃圾回收器调优都是减少STW时间。而Old
GC的STW时间一般是YoungGC的几倍到几十倍,而且占用CPU资源严重。所以,我们优化的重点是让系统减少Old
GC的次数。最好让系统只有YoungGC,没有Old GC,更没有Full GC
参考线上我们服务GC的stw时间,ygc一次约20ms+ , fullgc:200ms+

所以,优化的重点就是尽量不要让对象进入老年代。如果对象进不去老年代,想Full GC都难。这是JVM调优的重点。(当然了引起fullgc的还有元空间满了
or 堆外空间满了)
减少Full GC的另一个好处就是减少cpu资源消耗。
jvm优化的另个地方就是GC本身,这个不是本文的主题,所以不展开讨论。

<>一.JVM分代模型(parnew + cms ,G1):年轻代、老年代、永久代

注:G1中也是这个模型,只不过G1中region的归属会动态调整
这里说一下G1的基本概念
G1把堆内存平均分成了多个大小相同的Region,我们首先要设置堆内存的大小,然后G1会根据堆大小除以2048,分成2048个大小相同的Region。

G1也是有年轻代、s0,s1、老年代的概念。Region属于年轻代还是老年代,由G1动态控制。系统默认给年轻代分配5%的Region来存放对象,年轻代最多可以占用60%的Region,这60%可以通过JVM参数指定,默认是60%,不过这个一般默认就好,如果达到了目标值,就会强制触发YoungGC

G1整体使用的都是复制回收算法。只是某些Region属于Eden,某些Region属于Survivor,系统新创建的对象会被分配到属于Eden的Region,如果垃圾回收就把存活对象复制到Survivor中。

G1最大特点就是可以设置一个预期的停顿时间(STW),比如,某个系统的时效性要就特别高,每次GC我只允许STW的5ms,那我们就可以通过JVM参数设置成5ms的停顿,这样G1在垃圾回收时,就会把时间控制在5ms以内(该值太低会导致频繁的GC,所以并不是越低越好)

<>二.对象在JVM内存中如何分配?如何流转的?

注:该图是按照G1分配对象来画的,和parnew+cms差不太多(详细的对象创建过程见 附录2)
对象分配首先在Eden中分配。如果是大对象cms中直接分配到老年代,而G1中单独拿出一些region来放大对象。(这里不考虑对象逃逸分析)

<>三.什么样的对象会被垃圾回收

通过可达性分析算法,判断是否被 GC Roots 引用。有GC Roots引用的对象不能回收,没有GCRoots引用的对象可以被回收,如果有GC
Roots引用,但是如果是软引用 or 弱引用,也有可能被回收掉。
没有GC Roots引用的对象一定立马被回收?可以重写finalize方法,让自己被引用。
对象引用:

* 强引用:
* 软引用:何时被垃圾回收?
*
SoftReference中有一个全局变量clock代表最后一次GC的时间点,有一个属性timestamp,每次访问SoftReference时,会将timestamp其设置为clock值。当GC发生时通过如下表达式判断是否要回收(true为不回收,false为回收):
clock - timestamp <= freespace * SoftRefLRUPolicyMSPerMB。
* clock -
timestamp:最后一次GC时间和SoftReference对象实例timestamp的属性的差。就是这个SoftReference引用对象大概有多久未访问过了
* freespace:JVMHeap中空闲空间大小,单位为MB。
*
SoftRefLRUPolicyMSPerMB:每1M空闲空间可保持的SoftReference对象生存的时长(单位ms)。默认1000,可以通过参数:-XX:SoftRefLRUPolicyMSPerMB进行设置。
* 弱引用:垃圾回收时
* 虚引用:用来跟踪对象被垃圾回收回收的活动。
<>四.对象晋升老年代

<>1. 动态对象年龄判断机制

*
假如说当前放对象的survivor区域里,一批对象的总大小大于了这块survivor区域的内存大小的50%,那么此时大于等于这批对象年龄最大的年龄的对象,就可以直接进入老年代了(一岁开始累加:1+2+。。+n>50%内存区域
,超过n年龄的对象进入老年代)。
<>2. 新生代垃圾回收之后,存活对象太多,导致大量对象直接进入老年代

*
这里优化思路是:主要是在Survivor的大小这块下功夫。我们要避免动态年龄审核和Survivor放不下的情况。要想保证这点,我们就要知道,我们系统的高峰时期,JVM中每秒有多少对象新增,每次YoungGC存活了多少对象。如meimiao这就需要用
jstat 了
<>3. 大对象直接进入老年代

* -XX:PretenureSizeThreshold 定义多大才是大对象,默认0意思说所有对象都在eden中分配 (字节数);G1中呢?
* 需要注意避免频繁的大对象进入老年代,造成频繁的full gc
<>4. 对象躲过了15次垃圾回收(默认),进入老年代

* -XX:MaxTenuringThreshold (可用通过调低该参数,让某些长久存活的对象赶紧进入老年代,释放s区空间)
* 该参数只有第一次设置的时候有效,后续有动态年龄判断控制,比如当触发动态年龄判断的时候年龄是n,该参数在会动态调整为N+1
*
调低该参数,降低动态年龄判断机制触发的概率。如果系统1分钟或者30秒一次YoungGC,那没必要非得让对象存活十几分钟才进入老年代,一般存活个两三分钟,这个对象大概率就是要存活很久的了。所以,我们当时是调低了这个参数的,设置了5。不然这个对象一直存活,然后在两个Survivor里来回复制,如果这个对象小一点还好,如果这个对象挺大的,那容易触发Survivor的动态年龄审核机制,让一大批对象进入老年代。
<>5. 空间担保机制

<>1. 当要MinorGC之前,首先会计算老年代剩余空间是否大于新生代所有对象大小之和(防止极端情况下eden区所有对象都幸存)。
- 如果老年代剩余内存可以放得下年轻代所有对象,那么你尽管去MinorGC,肯定不会OOM。
<>2.
如果剩余空间不够,但是配置了-XX:-HandlePromotionFailure参数(1.6以后废弃),那么就会计算每次MinorGC后存活对象的平均大小,如果老年代剩余内存大小大于这个平均大小,则大胆认为这次MinorGC回收后,老年代还是可以放得下。
- 试想不配该参数,eden区对象本身就朝生夕死,就会造成jvm过于悲观的判断内存不够,而频繁的full gc <>3.
如果该次MinorGC之后老年代的确是放不下就进行Fulll GC,如果Full GC完了还是放不下则oom <>4.
所以在MinorGC的时候,可能会发生FullGC
<>五.触发GC时机

<>1.cms

* 年轻代满了 元空间满了
* 老年代可用空间小于新生代全部对象的大小,如果没开空间担保,直接Full GC 。
* 老年代可用内存小于历次新生代GC后进入老年代的平均大小,此时会提前Full GC
* 新生代minor GC后存活对象大于Survivor,那么就会进行老年代,此时老年代内存不足
* 老年代内存使用率超过92%(可调)
* 浮动垃圾大于cms可用空间。
<>2.G1

G1的垃圾回收不一定是年轻代满了,或者老年代满了才去回收。如果是那样,就和ParNew+CMS没区别了,大内存机器也要STW好久。

* 回收年轻代

G1是基于每个Region的性价比去回收的,比如,Region1里有20M对象,回收2ms,Region2里有50兆对象回收要4ms。如果我们设置系统停顿时间为5ms,那G1会在要求的时间内,尽可能回收更多的对象,它会选择Region2,因为性价比更高。所以,我们系统运行,一直往Eden放对象,如果G1觉得,此时回收一下垃圾,差不多要5ms,那可能G1就回去回收,不会等到年轻代占用60%才去回收。
* 混合回收
G1的Old
G也不是我们能控制的,如果老年代占比45%,就会触发混合回收,回收整个堆内存,但是混合回收也是会控制在我们设置的停顿时间的范围内的,如果时间不够,就会分多次回收。

混合回收不仅会回收老年代,还会回收新生代和大对象。如果一次性全回收掉,那时间就太久了,可能达不到我们设置的预期停顿时间,所以G1这里是分几批来回收的,回收一次,系统运行一会,然后再回收一次。JVM参数可以设置这个值,分几次去回收,默认值是8次,分8次回收。混合回收还有一个参数我们可以设置,就是空闲的Region达到百分之多少,停止回收,默认是5%。
* Full GC
G1何时会触发Full GC,其实G1的混合回收就相当于ParNew + CMS的Full
GC了,因为回收了所有的区域,只不过回收时间可以控制在我们指定的范围内。但是G1的Full
GC就没法控制了,可能要卡顿特别久才能回收完。什么情况下会出现呢,因为G1的整体是基于复制算法的,如果回收的过程中,发现存活对象找不到可以复制的Region,放不下了。那就Full
GC,开始单线程标记、清理、整理空闲出一批Region,这个过程很慢。
<>六.一些模拟场景

* 上线系统之后要借助一些工具(jstat)观察每秒种会新增多少对象在新生代里,然后多长时间触发一次Minor
GG,平均每次MinorGC之后会有多少对象存活,survivor区是否可以放的下。
* 假设4核8g机器,当前java启动参数为-Xms:4G -Xmx:4G 新生代和老年代比率为2:1 新生代:3G
默认对应s0:s1:eden=1:1:8 即300M:300M:2400M 老年代:1G
* 通过jstat分析得知系统没秒创建100m对象,那么24s后eden区满了,因为此时2400M>1000M,空间担保机制判断此时新生代对象 >
老年代1G内存,默认-XX:HandlePromotionFailure开启,
那么此时判断老年代空闲内存1G大于每次进入老年代的平均对象的大小0,此时进行minor gc,
*
假设存活的对象为400M,那么此时通过复制算法复制到survivor0区域时发现内存不够用则将400M对象直接进入到老年代,然后在隔24s后再次触发minor
gc,此时老年代对象为800M,第三次触发minor
gc时发现空间担保机制失败因为老年代空闲内存200M<平均进入老年代对象大小即400M,此时触发一次full gc来回收老年代的内存。此时minor
gc:24s触发一次 ,FullGC即三次minor gc触发一次
* 假设存活的对象为100m,第一次ygc放入s0,s0剩余200m,第二次ygc eden存活100m、s0存活80 100+80 >300/2
触发动态年龄判断,部分对象被迫升入老年代中 一段时间后触发FullGC
* 优化方案:调整Young区比例使s区可以放下存活对象
* 巴拉巴拉~~
<>附录1:垃圾回收过程

<>描述堆空间的一些数据结构

说垃圾回收过程之前先简单说下描述堆空间的一些数据结构:

* 标记位图:用来标记存活对象。位图中每位描述堆中8个字节,存活标记为1,(为什么需要对象空间对齐)
* 卡表:不是记录引用关系,主要用来描述堆使用情况。由大小1B的数组实现,一个元素描述堆中512B,脏卡表示该区域有被引用,净卡。。
* Rset(转移专用记忆集合):用来描述对象引用关系的。(cms中就是跨代)
* 作用:记录区域之间的引用关系,每个区域有一个,通过point
in(谁引用我)方式记录(某个分区里的对象有可能被很多区域引用,也可能被一个区域的很多对象引用)。
*
结构:OtherRegionsTable,每个HeapRegion都包含一个HeapRegionRemSet结构,该结构里的OtherRegionsTable
也就是PRT(Per region Table) 来描述对象的引用,由三种粒度组成
* 细粒度PRT:PRT数组实现,每一项表示一个区域引用该区域的情况。记录其他区域的哪些对象引用该区域的对象
*
稀疏PRT:通过hash表实现。key是引用本区域的其他区域地址,值是一个数组,数组的元素是引用方其他区域地址对应的卡表位置(通过位图表示)。记录其他区域的那些对象引用该区域
* 粗粒度PRT:通过位图标识,每一位表示对应的分区有引用到该分区的引用 。只是记录有哪些区域引用改区域。只是记录有哪些区域引用改区域
* 为什么需要三种不同粒度描述引用? 一个对象被引用次数不固定,为了效率和空间的均衡。
* Rset里的对象可以认为就是根对象
* 引用关系记录时机:当对象被修改时,被修改对象所对应的卡片会被转移专用写屏障记录到Rset中
<>CMS

* 初始标记:标记根直接引用的对象(STW,防止根被修改),JVM 有个参数是初始标记阶段多线程标记,减少STW时间,正常是单线程标记的。
*
并发标记:并发标记步骤1中标记的对象所引用的对象。因为是并行进行,所以允许新的对象进来,也会有标记存活的但是现在变成垃圾,这些有改动的对象JVM都会记下来,等待下一步处理。该步较消耗擦CPU资源,消耗(cpu核数
+ 3)/4 。CPU负载高的话要考虑是否频繁的FullGC。
* 重新标记:重新标记并发标记中有改动的对象(STW)。相较第一步要比第一步慢因为要重新判断整个对象是否GC可达。
这里也可以通过JVM参数优化,可以通过参数控制,让CMS在重新标记阶段之前尽量触发一次Young
GC(尽量YoungGC是因为可能新生代可能刚刚YoungGC不久,那此时就没必要再一次YoungGC了)这样做的好处是减少了并发标记中改动的对象,缩短STW时间。虽然YoungGC也会造成停顿,但是YoungGC一般频率是比较快的,早晚都要执行,现在执行一举两得。
* 并发清理:清理前几个阶段标记好的垃圾(并行)。
*
该步骤和用户线程并行进行,所以允许对象进入老年代,进而产生浮动垃圾。试想一个问题,如果产生的浮动垃圾超过cms可用内存空间,此时会怎样?Concurrent
Mode Failure问题:
1. cms在垃圾回收期间会预留一部分空间,防止该情况。
* 参数:-XX:CMSInitiatingOccupancyFraction=70 当老年代达到70%时,触发CMS垃圾回收。
*
如果CMSInitiatingOccupancyFraction在0~100之间,那么由CMSInitiatingOccupancyFraction决定。
* 否则由按 ((100 - MinHeapFreeRatio) + (double)( CMSTriggerRatio *
MinHeapFreeRatio) / 100.0) / 100.0 决定
* 如果浮动垃圾超过cms可用空间,这个时候就并发垃圾回收失败了,自动使用Serial Old 垃圾回收器代替CMS,然后STW
* 由于cms算法问题,清理完后会产生内存碎片,jvm会根据jvm参数来选择,Full GC之后是否进行碎片整理,或者选择几次Full
GC之后来一次内存整理。
1. -XX:+UseCMSCompactAtFullCollection :默认打开,Full GC之后就STW 进行内存整理
2. -XX:+CMSFullGCsBeforeCompaction:默认0,n次Full GC后来一次内存整理。
* 最后,我们通过JVM参数设置,每次Old GC后都重新整理内存,整理阶段会把老年代零零散散的对象排列到一起,减少内存碎片
<>G1

* 初始标记
暂停标记(STW),该过程会创建标记位图,标记出根直接引用的对象。
注:为什么要暂停标记?防止根被修改。g1中是通过写屏障技术来感知对象引用变更的,但是有一部分对象是不能通过该技术感知到,所以直接暂停了
* 并发标记
* 并发标记步骤1中标记的对象所引用的对象,新增对象直接被标记为存活
*
和应用程序并行,所以引用可能被修改:通过SATB专业写屏障技术监听引用变更,将变更写入STAB队列中(每个线程有一个),当队列大于1kb后写入STAB总集中,GC线程扫描STAB总集合中对象。
* 疑问:如果并发标记结束后SATB队列没满1kb怎么办?见下一个步骤
* 最终标记
-暂停处理,未满1kb的STAB队列,不会放入SATB总集中,所以需要单独处理
* 收尾工作
存活对象计数,清空标记位图。计算转移效率并按照转移效率降序排序
<>附录2:对象创建过程

<>1 对象的创建

在语言层次上,创建对象(克隆或反序列化)通常仅仅是一个new关键字而已,而在虚拟机中,对象(这里只讨论普通java对象,不包括数组和Class对象等)的创建是怎么一回事呢?

*
虚拟机遇到一条new指令时首先将去检查这个指令是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有则必须先执行相应的类加载过程。
*
类加载检查通过后,虚拟机将为新生对象分配内存,对象所需内存大小在类加载完成后便可完全确定了(如何确定在后续会讲),等同于把一块确定大小的内存从java堆中划分出来。
* 对象内存分配有两种方式,“指针碰撞”、“空闲列表”。具体使用哪种分配方式取决于Java堆中的内存是否规整。
*
指针碰撞:假设Java堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在一边中间放着一个指针作为分界点的指示器,那所分配的内存就仅仅是把那个指针向空闲那边移动一段对象大小相等的距离,这种分配方式称为“指针碰撞”
*
空闲列表:如果Java堆中的内存不是规整的,已使用的内存和空闲的内存相互交错,就没办法使用指针碰撞了,虚拟机就必须维护一个列表记录哪些内存块是可用的,在分配的时候在列表内存找到一块足够的的内存划分给对象实例,并更新列表上的数据。
*
注:选择哪种分配方式取决于Java堆是否规整,而Java堆是否规整又由所采用的垃圾回收器是否带有压缩整理功能决定。因此在使用Serial,ParNew等带Compact过程的收集器时系统采用的分配算法是指针碰撞,而使用CMS这种
基于Mark-Sweep算法的收集器时,通常采用的是空闲列表。
* 值得注意的是对象分配并不是线程安全的。解决这个有两种方案:
* 虚拟机采用CAS配上失败重试方式保证更新操作的原子性
*
虚拟机为每个线程在堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB)。哪个线程要给分配内存,就在哪个线程的TLAB上分配,只有在TLAB用完并分配新的TLAB时,才需要同步锁锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTlab参数来决定
* 内存分配完成后,虚拟机要将分配到的内存空间都初始化为零值(不包括对象头),这一步保证了对象的实例字段在java代码中可以不赋初始值就直接使用
* 接下来,虚拟机要对对象进行必要的设置,例这个对象属于哪个类的实例,类的元数据信息位置,对象的哈希码、GC分代年龄等信息。这些信息放在对象头之中。
*
上面的工作完成,从虚拟机的角度来看一个新的对象产生了,从java的角度看对象刚刚开始,所以一般来说,执行new指令后执行方法把对象按照程序员的意愿进行初始化,到这一个真正可用的对象才算完全产生出来。
<>对象的内存布局

对象在内存中存储的布局可以分三部分:对象头、实例数据、对齐空间

*
对象头:对象头包括两部分,1、存储对象自身的运行时数据(哈希码,GC分代年龄,锁状态标识,线程持有的锁,偏向线程id,偏向时间戳等);2、类型指针(虚拟机通过这个指针来确定这个对象是哪个类的实例);注:如果对象是一个java数组,拿在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通对象的元数据确定对象的大小,但从数组的元数据中无法确定数组的大小。
* 实例数据:真正存储的有效信息
* 对齐填充:该部分起到占位符的作用(HotSpot VM规定对象的起始地址必须是8字节的整数倍,对齐填充就是用来填补的;
看过附录1的同学应该已经知道为什么要对齐了,装起来!)
<>附录3:jvm配置参数

<>一:jvm参数

* -Xms:java堆内存的大小;-Xmx:java堆内存的最大大小
* -Xmn:新生代大小;-Xmn515M
* -XX:PermSize : 永久代大小;-XX:MaxPermSize : 永久代最大大小;
* jdk1.8以后替换为了 -XX:MetaspaceSize(该参数不是元空间的初始化大小,而是说到了该大小以后去扩容) 和
-XX:MaxMetaspaceSize 例: -XX:PermSize=128M
* -Xss: 每个线程栈内存大小 一般1m妥妥够用
* -Xss是OpenJDK和Oracle JDK的-XX:ThreadStackSize的别名。-Xss可以接受带K,M或G后缀的数字;
* -XX:ThreadStackSize=需要一个整数(无后缀)-堆栈大小(以千字节为单位)。
* -XX:SurvivorRatio=8: 新生代比例
* -XX:MaxTenuringThreshold=5 : 对象晋升老年代年龄,默认15.
* -XX:TargetSurvivorRatio 默认50%,Survivor被占用多大会触发动态年龄判断
* -XX:+PrintGCDetils: 打印详细的GC日志
* -XX:+PrintGCTimeStamps: 打印出每次GC发生的时间
* -XX:+PrintHeapAtGC :打印GC前后堆内存使用情况,辅助调试用
* -Xloggc:gc.log : 将gc日志写入一个磁盘文件
* -XX:TraceClassLoading -XX:TraceClassUnloading 打印类的加载和卸载信息,调试用
* -XX:+DisableExplicitGC : 禁止显示执行GC,如System.gc()
* 另一个问题是说可能导致NIO 去回收堆外内存的时候失效
* -XX:SoftRefLRUPolicyMSPerMB =
1000:单位ms,默认1000,软引用回收的时间(需要结合当前堆剩余空间和软引用未被访问时间来细算 软引用的回收时间)
*
该参数不能设置为0,jvm为了优化反射速度会动态的生成类信息加载到元空间,这里的引用就是用的软引用,如何为0会导致反射频繁的创建类信息加载到元空间引起频繁fullgc
* -XX:+HeapDumpOnOutOfMemoryError oom时候导出一份dump文件
例:XX:HeapDumpPath=/chj/data/log/${appName}/dump.prof
<>二:cms相关参数

* -XX:CMSFullGCsBeforeCompaction=0 :cms多少gc进行一次内存压缩,
* 最好每次都进行整理,否则空间碎片过多容易导致FullGC频率升高
* -XX:+UseCMSCompactAtFullCollection :默认打开,Full GC之后就STW 进行内存整理,
* -XX:CMSInitiatingOccupancyFraction=70 当老年代达到70%时,触发CMS垃圾回收,需要预留出浮动垃圾空间。
* -XX:+UseCMSInitiatingOccupancyOnly
只是用设定的回收阈值(上面指定的70%),如果不指定,JVM仅在第一次使用设定值,后续则自动调整.
* -XX:+CMSScavengeBeforeRemark在CMS GC前启动一次ygc,目的在于减少old gen对ygc
gen的引用,降低remark时的开销-----一般CMS的GC耗时 80%都在remark阶段 ,减少stw时间
* -XX:+CMSParallellniialMarkEnabled:初始标记阶段开启多线程并发标记,减少stw时间
<>三:G1相关参数

* -XX:+UseG1GC 指定G1垃圾回收器
* -XX:G1HeapRegionSize 指定Region大小,默认就好
* -XX:G1NewSizePercent 设置新生代初始化占比,默认5%,默认就好
* -XX:G1MaxSizePercent 指定新生代最大占比,默认60%,默认就好
* -XX:SurvivorRatio=8 配置eden 和survivor区比例,具体多大G1自动控制
* -XX:MaxGCPauseMills 目标GC停顿时间,默认200ms
* -XX:InitiatingHeapOccupancyPercent 老年代堆内存占用多少的时候触发混合回收,默认45%
* -XX:G1MixedGCCountTarget 再一次混合回收的过程中,最后一个阶段执行几次混合回收,默认八次(防止STW时间太长)
* -XX:G1HeapWastePercent 混合回收时,如果回收的Region数据达到堆内存的5%,停止混合回收。默认5%
* -XX:G1MixedGCLiveThresholdPercent
默认85%,确认要回收的Region的时候,必须存活对象低于85%Region才可以回收
<>附录4:jstat

* jstat -gc pid 1000 10 :1000为多久打印一次(ms),10为打印几次
* Eden区的对象增长速率多快
* Young GG频率多高
* 一次YoungGC多长耗时
* YoungGC过后多少对象存活
* 老年代的对象增长速率多高
* Full GC频率多高
* 一次Full GC耗时
* S0C:年轻代中第一个存活区的大小
* S1C:年轻代中第二个存活区的大小
* S0U:年轻代中第一个存活区已使用的空间 (KB)
* S1U:年轻代中第二个存活区已使用的空间 (KB)
* EC: Edem区大小
* EU: 年轻代中Edem区已使用的空间 (KB)
* OC: 老年代大小
* OU: 老年代已使用的空间 (KB)
* MC: 元空间大小,其实这里显示的不是-XX:MetaspaceSize该参数配置的大小
* MU: 元空间已使用的空间 (KB)
* YGC: 从应用程序启动到采样时young gc的次数
* YGCT: 从应用程序启动到采样时young gc的所用的时间(s)
* FGC: 从应用程序启动到采样时full gc的次数
* FGCT: 从应用程序启动到采样时full gc的所用的时间
* GCT: 从应用程序启动到采样时整个gc所用的时间
jmap 导出一份堆dump文件,命令如下:
jmap -dump:format=b file=<文件名XX.hprof>

技术
下载桌面版
GitHub
Gitee
SourceForge
百度网盘(提取码:draw)
云服务器优惠
华为云优惠券
腾讯云优惠券
阿里云优惠券
Vultr优惠券
站点信息
问题反馈
邮箱:[email protected]
吐槽一下
QQ群:766591547
关注微信