JVM速记:内存模型、类加载、垃圾回收与调优实战解析
2026/9/15 18:36:37 网站建设 项目流程

直接说结论:JVM的面试题和实战问题,翻来覆去问的就是内存模型、类加载、垃圾回收、调优这几个大方向。很多人在准备时东看一眼西看一眼,知识点记住了但串不起来,面试时一说就散。这篇“JVM速记”就是帮大家把这些东西整理成一套可以随时调用的知识框架,既能应付面试,也能在线上出问题时知道从哪儿下手排查。不管你是刚接触Java的初学者,还是写过几年代码但一直没系统梳理过的开发者,这份内容都值得花半小时过一遍。

我写这东西的起因很简单:前阵子帮团队做了一次JVM相关的技术分享,为了准备材料把HotSpot源码、各种调优案例、面试高频题重新翻了一遍,发现很多以前模棱两可的点这次彻底想通了。整理出来的笔记被好几个同事要去做面试复习材料,所以干脆扩充成一篇完整的速记,把我认为最重要的理解和最容易踩的坑都写进来。

1. 运行时数据区:面试里80%的JVM题目都从这张图出

很多人上来就背“堆、栈、方法区、程序计数器、本地方法栈”,背得滚瓜烂熟,但一问“为什么需要分成这么多区域”就卡住了。我建议换个思路:不要按名字背,要按“每个区域到底放着谁的什么数据”来理解。

1.1 堆、栈、方法区的分工与协作

JVM的运行时数据区本质上是在回答一个问题:Java程序运行时,数据放在哪里、由谁管理、生命周期多长。

程序计数器是最小的一块区域,它记录当前线程正在执行的字节码指令地址。为什么需要它?因为线程切换后要能恢复现场。这个区域是唯一不会抛OOM的地方,因为它的空间需求是固定的,就是存一个地址。这个点很多面试官喜欢顺口问一句,答不上来会显得基础不牢。

虚拟机栈是线程私有的,生命周期跟线程一致。栈里面装的是栈帧,每个方法调用对应一个栈帧的入栈和出栈。栈帧里包含局部变量表、操作数栈、动态链接、方法返回地址。我之前给新人讲这块的时候喜欢打个比方:栈就像一叠便签纸,每调用一个方法就在最上面压一张,方法执行完就撕掉一张。如果递归太深不退出,便签纸堆得太高顶到天花板,就是StackOverflowError。

是最大的一块内存区域,所有的对象实例和数组都在这里分配。它是垃圾回收的主战场,所以后面聊GC的时候大头都在堆上。堆内部还分新生代和老年代,新生代里面又分Eden区和两个Survivor区(S0、S1),默认比例是8:1:1。这里有个经典面试衍生题:为什么需要两个Survivor区?答案是避免内存碎片化——复制算法需要两块大小相同的区域来回倒腾。我在实际调优中确实见过有人把SurvivorRatio配成1:1,让Survivor区大到没意义,白白浪费内存,这是新手容易犯的错。

方法区在JDK 8之后改名为元空间(Metaspace),存放类元信息、常量、静态变量、JIT编译后的代码等。JDK 7及以前方法区在堆内,叫永久代,经常因为类加载过多导致PermGen OOM。JDK 8改为本地内存后,默认不受堆大小限制,但如果加载的类实在太多,还是可能把物理内存耗光。这个演进背后的逻辑值得记住:永久代的大小很难准确预估,放到本地内存后可以按需分配,减少OOM风险。

还有本地方法栈,它是为native方法服务的,普通Java开发用到的情况很少,知道它是线程私有的、跟虚拟机栈类似就够了。

1.2 从OOM异常反推区域知识

我对团队里新人的建议是:用异常来反向记忆每个区域。因为每个区域OOM的表现和原因都不一样,遇到线上故障时能靠异常信息快速定位是哪块区域出了问题。

  • 堆OOMjava.lang.OutOfMemoryError: Java heap space,最常见。通常是对象太多或内存泄漏,用jmap dump后分析堆快照。
  • 元空间OOMjava.lang.OutOfMemoryError: Metaspace,常见于动态生成大量类的框架(如CGLIB、反射批量加载)。
  • 栈OOM/SOFStackOverflowError是递归过深;OutOfMemoryError: unable to create new native thread是线程数超过系统限制,本质上是无法再为线程栈分配内存。
  • 直接内存OOM:NIO里用了大量DirectByteBuffer,报错通常是OutOfMemoryError: Direct buffer memory

这里有个我踩过的坑:以前遇到过一次堆内存一直涨但不OOM的情况,因为代码里用了本地缓存又没设置过期时间,导致Full GC后老年代还是满的。排查时先用jstat -gcutil看了各区域占比,再dump堆分析,最后才找到是一个静态Map只往里塞不清理。这种问题光会背内存模型是解不了的,得把概念落到排查工具上。

提示:面试如果被问到“JVM运行时数据区哪些是线程共享的、哪些是私有的”,一句话就能答清——堆和方法区是共享的,程序计数器、虚拟机栈、本地方法栈是私有的。但别只丢结论,补一句为什么(比如栈必须私有才能保证线程执行上下文隔离),档次立刻不一样。

2. 类加载机制与双亲委派:从.class到对象的中间环节

看完内存模型,下一个必然要碰到的主题就是类加载。因为对象在堆上分配之前,类的元信息得先进入方法区,这个“进入”的过程就是类加载机制在做的事。

2.1 加载、验证、准备、解析、初始化的完整链路

类的生命周期有七个阶段,其中加载、验证、准备、解析、初始化是必须掌握的五步。面试时从头到尾说一遍不难,难的是说清楚每一步到底干了什么事。

加载阶段通过类的全限定名获取二进制字节流,把字节流转化为方法区的运行时数据结构,并在堆中生成一个Class对象作为访问入口。注意这里“获取字节流”不一定是class文件,也可以从jar包、网络、动态代理生成。

验证阶段是安全屏障,检查字节流是否符合Class文件格式规范,包括元数据验证、字节码验证等。我第一次看这块时觉得“这跟业务有什么关系”,后来才知道如果关闭验证(-Xverify:none),类文件里有恶意修改的字节码就可能危害JVM安全。不过为了启动速度,有些框架在特定场景下确实会跳过验证,这是取舍问题。

准备阶段是为类变量(static变量)分配内存并设置初始值。这里有个高频考点:在准备阶段,public static int value = 123;这个value的值是0而不是123,因为此时还没执行任何Java代码,把value设成123是初始化阶段的事。但如果变量是public static final int value = 123;,那准备阶段就会直接赋值123,因为ConstantValue属性在准备阶段就会生效。这个区别我在面试别人时经常问,答对的人不到一半。

解析阶段是把常量池中的符号引用替换为直接引用。符号引用就是字面量形式的定位,直接引用是真实的内存地址或句柄。这一步对理解方法调用很重要,Java的静态链接和动态链接就在这里分叉。

初始化阶段是真正执行类构造器<clinit>方法的阶段,为静态变量赋代码里写的值、执行静态代码块。这里有个经典问题:父类和子类的初始化顺序是什么?答案很简单——先父后子。但另一个问题就难一些:如果通过子类访问父类的静态字段,会触发父类初始化,但不会触发子类初始化。因为静态字段解析到的是父类的引用,子类只是个入口。

2.2 双亲委派为什么是默认方案,又为什么会被打破

双亲委派模型的规则:当一个类加载器收到加载请求时,先把这个请求委派给父加载器,每一层都往上抛,直到顶层的启动类加载器(Bootstrap ClassLoader)。只有父加载器反馈自己无法加载时,子加载器才尝试自己加载。

这样做最核心的收益是保证Java核心库的类型安全。举个例子,如果没有双亲委派,你自己写一个java.lang.String,就可能跟JDK自带的String冲突,甚至替换掉核心类,引发不可预知的错误。有了双亲委派,加载java.lang.String的请求最终都会回到Bootstrap ClassLoader,确保你写的那个同名类永远没机会被正常加载。

但双亲委派不是万能的,它有一个著名的例外:JDBC。Java的核心类库中定义了java.sql.DriverManager,但它要加载的数据库驱动实现(比如MySQL的Driver)是由各个厂商提供的,存放在classpath下,Bootstrap ClassLoader根本加载不到。为了解决这个问题,JDK引入了线程上下文类加载器,通过Thread.currentThread().getContextClassLoader()反向让父加载器去请求子加载器完成加载,这等于从底层模型上“打破”了双亲委派。

面试里被问到“如何打破双亲委派”时,标准答案有两个:一是继承ClassLoader并重写loadClass方法(绕过那层先父后的逻辑),二是使用线程上下文类加载器。我见过Tomcat等Web容器会自定义类加载器来实现应用之间类隔离,每个应用一个WebAppClassLoader,保证部署多个应用时同名类互不干扰,这也是对双亲委派模型的合理改造。工作里如果遇到两个框架引入同一个类但不同版本导致冲突,就能理解为什么要做这种隔离了。

提示:区分ClassNotFoundExceptionNoClassDefFoundError是实战中很容易踩的坑。前者是加载时找不到类,通常是依赖没引入或类名写错;后者是类在编译期存在但运行期初始化失败,比如静态代码块抛了异常,之后所有对那个类的引用都会报这个Error。线上排查时如果看到NoClassDefFoundError,去查静态初始化逻辑往往比查依赖更靠谱。

3. 垃圾回收机制:JVM最硬核也最容易被问懵的部分

垃圾回收是JVM面试的重灾区,也是平时调优的主要发力点。新手看到一堆收集器和参数就头大,我的建议是先抓住一条主线:怎么判断对象该回收,用什么算法回收,不同的收集器解决了什么问题。

3.1 判断垃圾与回收算法的底层逻辑

判断对象是否可回收的主流方案是可达性分析。从一组称为GC Roots的根对象出发,向下搜索引用链,搜索不到的对象判定为可回收。GC Roots包括:虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、本地方法栈中JNI引用的对象。

这里很多人容易跟引用计数法搞混。引用计数法是给每个对象加一个计数器,被引用就加一,失效就减一,减到零就回收。听起来简单,但解决不了循环引用问题——A引用B,B引用A,外部没人再引用它们,计数器却永远是1,导致这两个对象永远不被回收。JVM不用引用计数法,就是为了避开这个坑。我在面试时喜欢追问一句“可达性分析会不会漏掉循环引用的对象”,答案是不会,因为从GC Roots出发根本没有路径能到达它们。

回收算法主要有三种思路:

标记-清除是最基础的,先标记可回收对象,然后统一清除。缺点是产生大量内存碎片,后续分配大对象时可能找不到连续空间,提前触发另一次GC。

标记-复制把内存分成两块,每次只使用其中一块,回收时把存活对象复制到另一块,再清空当前块。它的代价是浪费一半空间,所以HotSpot没有对整个堆用这个算法,而是在新生代里用Eden和两个Survivor区做优化:Eden区分配新对象,GC后存活对象复制到S0,下次GC时Eden和S0的存活对象一起复制到S1,如此往复。默认Eden:Survivor=8:1,也就是说只有10%的空间会闲置,比原来浪费50%好得多。

标记-整理针对老年代,标记存活对象后把它们向内存一端移动,然后清理边界以外的空间。它解决碎片问题,但移动对象的成本比清除高。所以老年代的GC停顿通常更长,这是物理限制,不是实现偷懒。

3.2 收集器演进路线:Serial到G1再到ZGC

不同收集器解决的问题不一样,按时间线记最好记:

Serial是最古老的单线程收集器,GC时必须暂停所有工作线程(Stop The World),Client模式的默认选择,现在已经很少直接用。

Parallel Scavenge(新生代)+Parallel Old(老年代)是JDK 8默认组合,注重吞吐量,适合后台计算任务。我已经很久没配过了,因为默认值在大多数服务里表现已经可以接受。

CMS是第一个实现并发收集的垃圾回收器,目标是缩短STW时间。它用标记-清除算法,所以会有碎片问题,而且并发阶段会占用CPU资源,在老年代碎片严重时可能出现Concurrent Mode Failure,退化为Serial Old做Full GC,停顿时间反而更长。CMS在JDK 9开始被标记为废弃,JDK 14移除了,但很多老项目还在跑,排查线上GC日志时一定要认识它。

G1是JDK 9之后的默认收集器,也是当前主流。它的创新在于把堆划分成多个大小相等的Region,不再严格区分新生代和老年代的物理区域,而是让Region动态扮演不同角色。G1可以用-XX:MaxGCPauseMillis设定目标停顿时间,它通过跟踪每个Region的回收价值(能回收多少空间、需要多少时间)来优先回收最有价值的Region,这叫Garbage First。G1很适合大堆和低延迟场景,但要注意它适合堆大小在4GB以上的场景,小堆上优势不明显。

ZGC代表最新的低延迟方向,目标是让STW时间控制在10ms以内。它用了染色指针和读屏障等技术,实现在GC过程中绝大部分阶段与业务线程并发执行。我实际测过ZGC在大堆下的表现确实惊艳,但它对JDK版本有要求,且在某些场景下吞吐量会略微下降。选不选它取决于业务对延迟的敏感度:如果线上有大量长尾请求对GC停顿敏感,ZGC值得尝试;如果是纯后台批处理,Parallel反而更合适。

3.3 三色标记与漏标问题

G1和ZGC都涉及并发标记,面试里躲不开三色标记算法。把对象分成三种颜色:

  • 白色:尚未被扫描到,标记结束后仍为白色的对象会被回收
  • 灰色:自身被访问到,但还没扫描完它所引用的对象
  • 黑色:自身和它引用的对象都扫描完了,标记完成

并发标记最大的风险是漏标——本来存活的对象被当成垃圾回收掉。漏标必须同时满足两个条件:黑色对象新增了一个指向白色对象的引用,同时所有能到达该白色对象的其他路径都被切断了。解决办法有两个方向:一是写屏障,在黑色对象新增引用时,把引用记录下来并在重新标记阶段扫描(增量更新);二是原始快照(SATB),标记开始时记录当时的引用关系快照,只要某个对象在快照中被某个灰色对象引用,即使后续引用被切断也不会被回收。G1用的就是SATB方案,CMS用的是增量更新。理解了这个区别,面试时把概念跟具体收集器对应起来,比单纯背名词要深刻得多。

提示:GC调优的第一原则是别瞎调。不要一上来就改一堆参数,先通过jstat -gcutil观察各代的使用情况,确认是分配过快、晋升过早还是有泄漏,再决定动哪个参数。我见过太多人在没定位问题的情况下把-Xmx调大一倍,结果只是把崩溃延后了而已。

4. 调优工具箱与两个高频报错的完整排查链路

聊完机制层面的东西,来点真正能落地的调优内容。我在排障时最常用的工具是JDK自带的命令行工具,不用装额外的东西,线上环境一般都有。

4.1 先学会看数据再谈调优

jps:列出当前JVM进程及启动参数。用法就不多说了,人人都会。

jstat:看JVM统计信息,最常用的是jstat -gcutil pid 1000,每秒打印一次GC各代使用率和GC时间。我调优时第一步永远是它,先看新生代Eden区是否每次GC后都几乎满、老年代增长曲线是否正常、Full GC频率和耗时是否离谱。

jmap:dump堆快照和查看堆内存摘要。jmap -dump:live,format=b,file=heap.hprof pid可以拿到一份堆快照,再用MAT或者VisualVM分析。注意在高峰期执行dump会STW,对线上有影响,尽量在低峰期操作,或者用jmap -histo:live先看对象总量分布。

jstack:打印线程快照,主要用于排查线程死锁、线程卡死、CPU飙升问题。CPU飙高时先top -Hp pid找到占用最高的线程id,转成十六进制后去jstack输出里搜,就能定位到具体代码行。这个技能我几乎每个月都会用到。

jcmd:JDK 7之后推出的多功能工具,很多功能可以替代jmap和jstack的部分用法,而且官方更推荐它。比如jcmd pid GC.class_histogram可以看类直方图,jcmd pid VM.flags可以看生效参数。

下面是常用命令速查,建议收藏:

目的命令
查看JVM进程jps -lv
看GC情况jstat -gcutil pid 1000
堆dumpjmap -dump:live,format=b,file=heap.hprof pid
线程快照jstack pid
看生效参数jcmd pid VM.flags
立即Full GCjcmd pid GC.run

4.2 “expiring daemon because jvm heap space is exhausted”的排查全过程

这个报错在开发者的本地环境特别常见,尤其是用Gradle构建项目时。我今年就帮两个同事解决过,症状是构建到一半Gradle Daemon进程崩溃,日志里出现这句话。

它的直接原因是:Gradle Daemon是个常驻JVM进程,用来加速构建,但它默认的堆内存如果不够大,构建时类加载、增量编译、单元测试都需要大量内存,堆空间耗尽就死了。

排查链路第一步是确认Daemon的堆大小配置。Gradle的配置文件在~/.gradle/gradle.properties,里面可以设置org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m。如果这个文件里没配或者配得偏小,遇到大项目就会崩。第二步是看系统总内存够不够,如果机器本身内存就不多,再调Daemon堆上限也白搭,可能需要限制IDE等其他进程的内存占用。第三步是确认是不是有多个Gradle Daemon同时跑。gradle --status可以查看当前有多少Daemon进程,多了就耗内存,可以用gradle --stop把所有Daemon停掉再重新构建。

我个人的建议配置:中小型项目-Xmx2048m起步,大型项目-Xmx4096m,同时把org.gradle.daemon=true保持开启,避免每次构建都重新起JVM。如果构建时还要跑大量的单元测试,再往上加一些。调完之后用gradle --status确认Daemon启动参数生效,这个问题基本就解决了。

4.3 “cannot collect jvm options caused by: 0: cannot read”报错的路径与权限陷阱

这个报错我是在用IntelliJ IDEA时遇到的,当时一打开IDE就弹错,提示类似于cannot collect jvm options caused by: 0: cannot read: "d:v作业实训 vjetbrain_..."。一眼看过去就知道问题出在路径上:Windows路径中的冒号和中文目录名被解析错了,d:v被当成了某种特殊格式,后面的中文加字母组合看起来像乱码。

这个报错的本质是IDE启动时需要读取jvmoptions配置文件,但路径格式不对导致文件无法读取。常见原因有三个:

第一,路径写错了或者在配置里用了不规范的写法。进入IDE的Help -> Edit Custom VM Options,检查里面的路径配置是否正确,不要出现Windows路径里常见的\转义问题。我发现一个规律:很多人喜欢把IDE或者项目放在带中文、带空格、带特殊符号的目录下,这在国内用户里尤其常见。不是不能用,但遇到工具链兼容性问题时,这些目录往往就是源头。

第二,启动脚本或环境变量里指定了不存在的路径。检查IDEA_VM_OPTIONS环境变量,如果指向的文件不存在,IDE启动时就会报类似的错。解决办法是把环境变量改到有效位置,或者直接删掉这个环境变量让IDE用默认配置。

第三,文件权限问题。配置文件虽然在,但当前用户没权限读。Windows下比较少见,Linux服务器上装JetBrains系的东西倒是经常遇到,chmod 644一下就能解决。

排查路径建议按顺序来:先看报错里给的路径具体是什么,确认有没有拼写问题;再检查环境变量有没有指向死路径;最后看文件权限。我之前在帮同事处理时,最后发现是因为他在vmoptions文件里写了一个自定义参数时用错了分隔符,把一个完整的参数拆成了两截,IDE解析时自然就挂了。所以改配置时,每个参数一行,等号和冒号不要随意变更,改完重启IDE再验证。

4.4 一个可复用的线上调优决策流程

调优不是玄学,我总结了一个每次都能用的流程,分享出来:

第一步,明确目标。你是想让接口快一点、吞吐量高一点,还是让Full GC不再频繁?不同的目标对应的调优方向完全不同。把目标量化出来,比如“Full GC次数从每小时10次降为0次”,没有量化指标的调优都是耍流氓。

第二步,采集数据。用jstat看GC频率和时间,用jmap看堆使用分布,用top看CPU和内存。至少观察几个完整的GC周期,不要看到一次异常就动手。

第三步,定位根因。GC频繁和GC时间长是两个问题:频繁通常是对象分配太多或存活对象太多;时间长通常是大对象进入老年代或者老年代空间太大导致标记整理耗时。把问题分类之后,才能决定调什么参数。

第四步,小步调整。一次只改一个参数,改完观察一段时间,确认效果后再改下一个。不要一上来就-Xmx8g -Xms8g全部拉满,风险太大,回头出了新问题都不知道是哪个参数引起的。

第五步,验证与回滚预案。保留修改前的参数记录,调优后如果吞吐量或延迟没有实质改善,果断回滚。

5. 面试高频考点速答:JVM、JRE、JVM内存模型与垃圾回收器

最后这部分,我把热搜词里提到的面试相关问题全部揉在一起,做成一个速答清单。直接背结论可以应付大多数面试,当然我强烈建议你在理解上面内容之后再来看这份清单,效果完全不同。

JDK、JRE、JVM三者什么关系?JDK是Java开发工具包,包含JRE和开发工具(javac、jdb等);JRE是Java运行环境,包含JVM和核心类库;JVM是Java虚拟机,是JRE的核心组成部分。一句话:JDK用于开发,JRE用于运行,JVM保证跨平台。面试时可以补一句“所以只要装了JRE就能运行Java程序,但要编译Java源码必须装JDK”。

JVM内存模型和运行时数据区是一回事吗?不是,这是面试里最容易混淆的两个概念。运行时数据区描述的是JVM在运行时把内存分成哪些区域来存数据;而JVM内存模型(JMM)是Java内存模型,定义的是多线程环境下变量的可见性规则。JMM解决的是并发编程中共享内存的原子性、可见性、有序性问题,和运行时数据区完全是两个层面的东西。面试时主动把这个区别讲清楚,会加分不少。

JVM的工作原理是什么?一句话版本:Java源码编译成字节码(class文件),JVM的类加载机制把class文件加载进内存,字节码经过解释器或JIT编译器转化为机器码执行。完整的回答应该覆盖:类加载(加载到初始化)、运行时数据区分配(对象存堆、局部变量存栈)、垃圾回收(自动管理内存)、执行引擎(解释执行+JIT编译)。我在面试时听到能把“编译一次,到处运行”背后的原理串起来的候选人,基本都会给高分。

对象一定在堆上分配吗?不一定。JIT编译期间,逃逸分析把不会逃逸出方法作用域的对象做标量替换,直接分配到栈上,方法结束就自动销毁,连GC都省了。这个知识点很多人听过但没实际验证过,面试里能主动说出来,会被认为对现代JVM有了解。

怎么选择垃圾回收器?给出判断维度:先用-XX:+PrintGCDetails看现在的GC情况,确认痛点。追求吞吐量选Parallel组合,追求低延迟选G1或ZGC,单机小堆选Serial也无不可。但一定要记住:默认的G1在绝大多数场景下已经够好,不要为了“用过更多收集器”而去调整默认配置。

JVM调优到底调什么?回答框架:堆大小(-Xms/-Xmx)、新生代比例(-Xmn、-SurvivorRatio)、GC选型(-XX:+UseG1GC等)、GC日志配置(-Xloggc、-XX:+PrintGCDetails)、OOM处理(-XX:+HeapDumpOnOutOfMemoryError)。调优的产出从低到高排序:先保证不崩,再减少GC停顿,最后追求高吞吐。

下面这张简表是我给新人准备的常见参数速查:

参数含义
-Xms/-Xmx堆初始大小 / 堆最大大小,建议设成一样,避免动态扩容抖动
-Xmn新生代大小
-XX:SurvivorRatioEden区与Survivor区的比例,默认8
-XX:MaxMetaspaceSize元空间上限
-XX:+HeapDumpOnOutOfMemoryErrorOOM时自动dump堆快照,线上必开
-XX:+PrintGCDetails打印GC细节日志,JDK 9后可配到独立日志文件
-XX:MaxGCPauseMillisG1期望的最大停顿时间,只作为目标,不保证一定达成

我个人在面试时还有一个必问的延伸题:如果有一个线上服务,GC频率正常但每次Full GC要2秒,你会怎么查?这个问题没有标准答案,但我期望的思路是:先确认老年代有多大、是不是有大对象一直在往里塞,然后看每次Full GC都扫了哪些Region、是不是有超大对象导致标记和复制时间过长,再用jmap -histo:live看有没有大数据结构的对象,最终可能是代码层面的问题——比如一个不小心加载了几十万条记录进内存。这类问题比单纯背概念更能看出一个人的真实经验。

写这份速记的过程中,我最大的感受就是:JVM的知识不是孤立的,内存模型、类加载、垃圾回收、调优工具是一条线上的不同节点。你只要把这条线串通了,面试题不管怎么变,都能回到几个核心原理上;线上出了问题,也能按同样的思路一步步往下查。如果时间有限,优先把运行时数据区和垃圾回收吃透,这两块占了面试和实战的七成以上。剩下的,一边写代码一边积攒经验就行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询