前些天,有人問(wèn)到 “開發(fā)過(guò)程中常見的內(nèi)存泄漏都有哪些?”,一時(shí)脫口而出:靜態(tài)的對(duì)象中(包括單例)持有一個(gè)生命周期較短的引用時(shí),或內(nèi)部類的子代碼塊對(duì)象的生命周期超過(guò)了外面代碼的生命周期(如非靜態(tài)內(nèi)部類,線程),會(huì)導(dǎo)致這個(gè)短生命周期的對(duì)象內(nèi)存泄漏。總之就是一個(gè)對(duì)象的生命周期結(jié)束(不再使用該對(duì)象)后,依然被某些對(duì)象所持有該對(duì)象強(qiáng)引用的場(chǎng)景就是內(nèi)存泄漏。
這樣回答很明顯并不是問(wèn)答人想要的都有哪些場(chǎng)景,所以這里抽時(shí)間整理了下內(nèi)存相關(guān)的知識(shí)點(diǎn),及LeakCanary工具的原理分析。
Java內(nèi)存問(wèn)題 及 LeakCanary 原理分析
在安卓等其他移動(dòng)平臺(tái)上,內(nèi)存問(wèn)題顯得特別重要,想要做到虛擬機(jī)內(nèi)存的高效利用,及內(nèi)存問(wèn)題的快速定位,了解下虛擬機(jī)內(nèi)存模塊及管理相關(guān)知識(shí)是很有必要的,這篇文章將從最基礎(chǔ)的知識(shí)分析,內(nèi)存問(wèn)題的產(chǎn)生地方、原因、解決方案等原理。
一、運(yùn)行時(shí)內(nèi)存區(qū)域
這里以Java虛擬機(jī)為例,將運(yùn)行時(shí)內(nèi)存區(qū)分為不同的區(qū)域,每個(gè)區(qū)域承擔(dān)著不同的功能。
方法區(qū)
用戶存儲(chǔ)已被虛擬機(jī)加載的類信息,常量,靜態(tài)常量,即時(shí)編譯器編譯后的代碼等數(shù)據(jù)。異常狀態(tài) OutOfMemoryError,其中包含常量池和用戶存放編譯器生成的各種字面量和符號(hào)引用。
堆
是JVM所管理的內(nèi)存中最大的一塊。唯一目的就是存放實(shí)例對(duì)象,幾乎所有的對(duì)象實(shí)例都在這里分配。Java堆是垃圾收集器管理的主要區(qū)域,因此很多時(shí)候也被稱為“GC堆”。異常狀態(tài) OutOfMemoryError。
虛擬機(jī)棧
描述的是java方法執(zhí)行的內(nèi)存模型,每個(gè)方法在執(zhí)行時(shí)都會(huì)創(chuàng)建一個(gè)棧幀,用戶存儲(chǔ)局部變量表,操作數(shù)棧,動(dòng)態(tài)連接,方法出口等信息。每一個(gè)方法從調(diào)用直至完成的過(guò)程,就對(duì)應(yīng)著一個(gè)棧幀在虛擬機(jī)棧中入棧到出棧的過(guò)程。 對(duì)這個(gè)區(qū)域定義了兩種異常狀態(tài) OutOfMemoryError、StackOverflowError。
本地方法棧
虛擬機(jī)棧為虛擬機(jī)執(zhí)行java方法,而本地方法棧為虛擬機(jī)使用到的Native方法服務(wù)。異常狀態(tài)StackOverFlowError、OutOfMemoryError。
程序計(jì)數(shù)器
一塊較小的內(nèi)存,當(dāng)前線程所執(zhí)行的字節(jié)碼的行號(hào)指示器。字節(jié)碼解釋器工作時(shí),就是通過(guò)改變這個(gè)計(jì)數(shù)器的值來(lái)選取下一條需要執(zhí)行的字節(jié)碼指令。
內(nèi)存模型
Java內(nèi)存模型規(guī)定了所有的變量都存儲(chǔ)在主內(nèi)存中。每條線程中還有自己的工作內(nèi)存,線程的工作內(nèi)存中保存了被該線程所使用到的變量,這些變量是從主內(nèi)存中拷貝而來(lái)。線程對(duì)變量的所有操作(讀,寫)都必須在工作內(nèi)存中進(jìn)行。不同線程之間也無(wú)法直接訪問(wèn)對(duì)方工作內(nèi)存中的變量,線程間變量值的傳遞均需要通過(guò)主內(nèi)存來(lái)完成。
為了保證內(nèi)存可見性,常常利用volatile關(guān)鍵子特性來(lái)保證變量的可見性(并不能保證并發(fā)時(shí)原子性)。
二、內(nèi)存如何回收
內(nèi)存的分配
一個(gè)對(duì)象從被創(chuàng)建到回收,主要經(jīng)歷階段有 1:創(chuàng)建階段(Created)、2: 應(yīng)用階段(In Use)、3:不可見階段(Invisible)、4:不可達(dá)階段(Unreachable)、5:收集階段(Collected)、6:終結(jié)階段(、Finalized)、7:對(duì)象空間重分配階段(De-allocated)。
內(nèi)存的分配實(shí)在創(chuàng)建階段,這個(gè)階段要先用類加載器加載目標(biāo)class,當(dāng)通過(guò)加載器檢測(cè)后,就開始為新對(duì)象分配內(nèi)存。對(duì)象分配內(nèi)存大小在類加載完成后便可以確定。
當(dāng)初始化完成后,虛擬機(jī)還要對(duì)對(duì)象進(jìn)行必要的設(shè)置,如那個(gè)類的實(shí)例,如何查找元數(shù)據(jù)、對(duì)象的GC年代等。
內(nèi)存的回收(GC)
那些不可能再被任何途徑使用的對(duì)象,需要被回收,否則內(nèi)存遲早都會(huì)被消耗空。
GC機(jī)制主要是通過(guò)可達(dá)性分析法,通過(guò)一系列稱為“GC Roots”的對(duì)象作為起始點(diǎn),從這些節(jié)點(diǎn)向下搜索,搜索所走過(guò)的路徑稱為引用鏈,當(dāng)一個(gè)對(duì)象到GC Roots沒(méi)有任何引用鏈時(shí),即GC Roots到對(duì)象不可達(dá),則證明此對(duì)象是不可達(dá)的。
根據(jù)《深入理解Java虛擬機(jī)》書中描述,可作為GC Root的地方如下:
- 虛擬機(jī)棧(棧幀中的局部變量區(qū),也叫做局部變量表)中引用的對(duì)象。
- 方法區(qū)中的類靜態(tài)屬性引用的對(duì)象。
- 方法區(qū)中常量引用的對(duì)象。
- 本地方法棧中JNI(Native方法)引用的對(duì)象。
當(dāng)一個(gè)對(duì)象或幾個(gè)相互引用的對(duì)象組沒(méi)有任何引用鏈時(shí),會(huì)被當(dāng)成垃圾處理,可以進(jìn)行回收。
如何一個(gè)對(duì)象在程序中已經(jīng)不再使用,但是(強(qiáng))引用還是會(huì)被其他對(duì)象持有,則稱為內(nèi)存泄漏。內(nèi)存泄漏并不會(huì)使程序馬上異常,但是多處的未處理的內(nèi)存泄漏則可能導(dǎo)致內(nèi)存溢出,造成不可預(yù)估的后果。
引用的分類
在JDK1.2之后,為了優(yōu)化內(nèi)存的利用及GC的效率,Java對(duì)引用的概念進(jìn)行了擴(kuò)充,將引用分為強(qiáng)引用、軟引用、弱引用、虛引用4種。
1、強(qiáng)引用,只要強(qiáng)引用還存在,垃圾收集器永遠(yuǎn)不會(huì)回收掉被引用的對(duì)象。
2、軟引用,在系統(tǒng)將要發(fā)生內(nèi)存溢出異常之前,將會(huì)把這些對(duì)象列進(jìn)回收范圍進(jìn)行二次回收。如果這次回收還沒(méi)有足夠的內(nèi)存,才會(huì)拋出內(nèi)存溢出異常。SoftReference表示軟引用。
3、弱引用,只要有GC,無(wú)論當(dāng)前內(nèi)存是否足夠,都會(huì)回收掉只被弱引用關(guān)聯(lián)的對(duì)象。WeakReference表示弱引用。
4、虛引用,這個(gè)引用存在的唯一目的就是在這個(gè)對(duì)象被收集器回收時(shí)收到一個(gè)系統(tǒng)通知,被虛引用關(guān)聯(lián)的對(duì)象,和其生存時(shí)間完全沒(méi)關(guān)系。PhantomReference表示虛引用,需要搭配ReferenceQueue使用,檢測(cè)對(duì)象回收情況。
關(guān)于JVM內(nèi)存管理的一些建議
1、盡可能的手動(dòng)將無(wú)用對(duì)象置為null,加快內(nèi)存回收。
2、可考慮對(duì)象池技術(shù)生成可重用的對(duì)象,較少對(duì)象的生成。
3、合理利用四種引用。
三、內(nèi)存泄漏
持有一個(gè)生命周期較短的引用時(shí)或內(nèi)部的子模塊對(duì)象的生命周期超過(guò)了外面模塊的生命周期,即本該被回收的對(duì)象不能被回收而停留在堆內(nèi)存中,這就產(chǎn)生了內(nèi)存泄漏。
內(nèi)存泄漏是造成應(yīng)用程序OOM的主要原因之一,尤其在像安卓這樣的移動(dòng)平臺(tái),難免會(huì)導(dǎo)致應(yīng)用所需要的內(nèi)存超過(guò)系統(tǒng)分配的內(nèi)存限額,這就造成了內(nèi)存溢出Error。
安卓平臺(tái)常見的內(nèi)存泄漏
1、靜態(tài)成員變量持有外部(短周期臨時(shí))對(duì)象引用。 如單例類(類內(nèi)部靜態(tài)屬性)持有一個(gè)activity(或其他短周期對(duì)象)引用時(shí),導(dǎo)致被持有的對(duì)象內(nèi)存無(wú)法釋放。
2、內(nèi)部類。當(dāng)內(nèi)部類與外部類生命周期不一致時(shí),就會(huì)造成內(nèi)存泄漏。如非靜態(tài)內(nèi)部類創(chuàng)建靜態(tài)實(shí)例、Activity中的Handler或Thread等。
3、資源沒(méi)有及時(shí)關(guān)閉。如數(shù)據(jù)庫(kù)、IO流、Bitmap、注冊(cè)的相關(guān)服務(wù)、webview、動(dòng)畫等。
4、集合內(nèi)部Item沒(méi)有置空。
5、方法塊內(nèi)不使用的對(duì)象,沒(méi)有及時(shí)置空。
四、如何檢測(cè)內(nèi)存泄漏
Android Studio供了許多對(duì)App性能分析的工具,可以方便分析App性能。我們可以使用Memory Monitor和Heap Dump來(lái)觀察內(nèi)存的使用情況、使用Allocation Tracker來(lái)跟蹤內(nèi)存分配的情況,也可以通過(guò)這些工具來(lái)找到疑似發(fā)生內(nèi)存泄漏的位置。
堆存儲(chǔ)文件(hpof)可以使用DDMS或者M(jìn)emory Monitor來(lái)生成,輸出的文件格式為hpof,而MAT(Memory Analysis Tool)就是來(lái)分析堆存儲(chǔ)文件的。
然而MAT工具分析內(nèi)存問(wèn)題并不是一件容易的事情,需要一定的經(jīng)驗(yàn)區(qū)做引用鏈的分析,需要一定的門檻。
隨著安卓技術(shù)生態(tài)的發(fā)展,LeakCanary 開源項(xiàng)目誕生了,只要幾行代碼引入目標(biāo)項(xiàng)目,就可以自動(dòng)分析hpof文件,把內(nèi)存泄漏的地方展示出來(lái)。
五、LeakCanary原理解析
A small leak will sink a great ship.
LeakCanary內(nèi)存檢測(cè)工具是由squar公司開源的著名項(xiàng)目,這里主要分析下源碼實(shí)現(xiàn)原理。
基本原理
主要是在Activity的&onDestroy方法中,手動(dòng)調(diào)用 GC,然后利用ReferenceQueue+WeakReference,來(lái)判斷是否有釋放不掉的引用,然后結(jié)合dump memory的hpof文件, 用HaHa分析出泄漏地方。
源碼分析
LeakCanary集成很方便,只要幾行代碼,所以可以從入口跟蹤代碼,分析原理
if (!LeakCanary.isInAnalyzerProcess(WeiboApplication.this)) {
LeakCanary.install(WeiboApplication.this);
}
public static RefWatcher install(Application application) {
return ((AndroidRefWatcherBuilder)refWatcher(application)
.listenerServiceClass(DisplayLeakService.class).excludedRefs(AndroidExcludedRefs.createAppDefaults().build()))//配置監(jiān)聽器及分析數(shù)據(jù)格式
.buildAndInstall();
}
從這里可看出,LeakCanary會(huì)單獨(dú)開一進(jìn)程,用來(lái)執(zhí)行分析任務(wù),和監(jiān)聽任務(wù)分開處理。
方法install中主要是構(gòu)造來(lái)一個(gè)RefWatcher,
public RefWatcher buildAndInstall() {
RefWatcher refWatcher = this.build();
if(refWatcher != RefWatcher.DISABLED) {
LeakCanary.enableDisplayLeakActivity(this.context);
ActivityRefWatcher.install((Application)this.context, refWatcher);
}
return refWatcher;
}
public static void install(Application application, RefWatcher refWatcher) {
(new ActivityRefWatcher(application, refWatcher)).watchActivities();
}
private final ActivityLifecycleCallbacks lifecycleCallbacks = new ActivityLifecycleCallbacks() {
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
}
public void onActivityStarted(Activity activity) {}
public void onActivityResumed(Activity activity) {}
public void onActivityPaused(Activity activity) {}
public void onActivityStopped(Activity activity) { }
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}
public void onActivityDestroyed(Activity activity) {
ActivityRefWatcher.this.onActivityDestroyed(activity);
}
};
void onActivityDestroyed(Activity activity) {
this.refWatcher.watch(activity);
}
具體監(jiān)聽的原理在于 Application 的registerActivityLifecycleCallbacks方法,該方法可以對(duì)應(yīng)用內(nèi)所有 Activity 的生命周期做監(jiān)聽, LeakCanary只監(jiān)聽了Destroy方法。
在每個(gè)Activity的OnDestroy()方法中都會(huì)回調(diào)refWatcher.watch()方法,那我們找到的RefWatcher的實(shí)現(xiàn)類,看看具體做什么。
public void watch(Object watchedReference, String referenceName) {
if(this != DISABLED) {
Preconditions.checkNotNull(watchedReference, "watchedReference");
Preconditions.checkNotNull(referenceName, "referenceName");
long watchStartNanoTime = System.nanoTime();
String key = UUID.randomUUID().toString();//保證key的唯一性
this.retainedKeys.add(key);
KeyedWeakReference reference = new KeyedWeakReference(watchedReference, key, referenceName, this.queue);
this.ensureGoneAsync(watchStartNanoTime, reference);
}
}
final class KeyedWeakReference extends WeakReference<Object> {
public final String key;
public final String name;
KeyedWeakReference(Object referent, String key, String name, ReferenceQueue<Object> referenceQueue) {//ReferenceQueue類監(jiān)聽回收情況
super(Preconditions.checkNotNull(referent, "referent"), (ReferenceQueue)Preconditions.checkNotNull(referenceQueue, "referenceQueue"));
this.key = (String)Preconditions.checkNotNull(key, "key");
this.name = (String)Preconditions.checkNotNull(name, "name");
}
}
private void ensureGoneAsync(final long watchStartNanoTime, final KeyedWeakReference reference) {
this.watchExecutor.execute(new Retryable() {
public Result run() {
return RefWatcher.this.ensureGone(reference, watchStartNanoTime);
}
});
}
KeyedWeakReference是WeakReference類的子類,用了 KeyedWeakReference(referent, key, name, ReferenceQueue<Object> )的構(gòu)造方法,將監(jiān)聽的對(duì)象(activity)引用傳遞進(jìn)來(lái),并且New出一個(gè)ReferenceQueue來(lái)監(jiān)聽GC后 的回收情況。
以下代碼ensureGone()方法就是LeakCanary進(jìn)行檢測(cè)回收的核心代碼:
Result ensureGone(KeyedWeakReference reference, long watchStartNanoTime) {
long gcStartNanoTime = System.nanoTime();
long watchDurationMs = TimeUnit.NANOSECONDS.toMillis(gcStartNanoTime - watchStartNanoTime);
this.removeWeaklyReachableReferences();//先將引用嘗試從隊(duì)列中poll出來(lái)
if(this.debuggerControl.isDebuggerAttached()) {//規(guī)避調(diào)試模式
return Result.RETRY;
} else if(this.gone(reference)) {//檢測(cè)是否已經(jīng)回收
return Result.DONE;
} else {
//如果沒(méi)有被回收,則手動(dòng)GC
this.gcTrigger.runGc();//手動(dòng)GC方法
this.removeWeaklyReachableReferences();//再次嘗試poll,檢測(cè)是否被回收
if(!this.gone(reference)) {
// 還沒(méi)有被回收,則dump堆信息,調(diào)起分析進(jìn)程進(jìn)行分析
long startDumpHeap = System.nanoTime();
long gcDurationMs = TimeUnit.NANOSECONDS.toMillis(startDumpHeap - gcStartNanoTime);
File heapDumpFile = this.heapDumper.dumpHeap();
if(heapDumpFile == HeapDumper.RETRY_LATER) {
return Result.RETRY;//需要重試
}
long heapDumpDurationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startDumpHeap);
this.heapdumpListener.analyze(new HeapDump(heapDumpFile, reference.key, reference.name, this.excludedRefs, watchDurationMs, gcDurationMs, heapDumpDurationMs));
}
return Result.DONE;
}
}
private boolean gone(KeyedWeakReference reference) {
return !this.retainedKeys.contains(reference.key);
}
private void removeWeaklyReachableReferences() {
KeyedWeakReference ref;
while((ref = (KeyedWeakReference)this.queue.poll()) != null) {
this.retainedKeys.remove(ref.key);
}
}
方法ensureGone中通過(guò)檢測(cè)referenceQueue隊(duì)列中的引用情況,來(lái)判斷回收情況,通過(guò)手動(dòng)GC來(lái)進(jìn)一步確認(rèn)回收情況。
整個(gè)過(guò)程肯定是個(gè)耗時(shí)卡UI的,整個(gè)過(guò)程會(huì)在WatchExecutor中執(zhí)行的,那WatchExecutor又是在哪里執(zhí)行的呢?
LeakCanary已經(jīng)利用Looper機(jī)制做了一定優(yōu)化,利用主線程空閑的時(shí)候執(zhí)行檢測(cè)任務(wù),這里找到WatchExecutor的實(shí)現(xiàn)類,研究下原理:
public final class AndroidWatchExecutor implements WatchExecutor {
static final String LEAK_CANARY_THREAD_NAME = "LeakCanary-Heap-Dump";
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final Handler backgroundHandler;
private final long initialDelayMillis;
private final long maxBackoffFactor;
public AndroidWatchExecutor(long initialDelayMillis) {
HandlerThread handlerThread = new HandlerThread("LeakCanary-Heap-Dump");
handlerThread.start();
this.backgroundHandler = new Handler(handlerThread.getLooper());
this.initialDelayMillis = initialDelayMillis;
this.maxBackoffFactor = 9223372036854775807L / initialDelayMillis;
}
public void execute(Retryable retryable) {
if(Looper.getMainLooper().getThread() == Thread.currentThread()) {
this.waitForIdle(retryable, 0);//需要在主線程中檢測(cè)
} else {
this.postWaitForIdle(retryable, 0);//post到主線程
}
}
void postWaitForIdle(final Retryable retryable, final int failedAttempts) {
this.mainHandler.post(new Runnable() {
public void run() {
AndroidWatchExecutor.this.waitForIdle(retryable, failedAttempts);
}
});
}
void waitForIdle(final Retryable retryable, final int failedAttempts) {
Looper.myQueue().addIdleHandler(new IdleHandler() {
public boolean queueIdle() {
AndroidWatchExecutor.this.postToBackgroundWithDelay(retryable, failedAttempts);//切換到子線程
return false;
}
});
}
void postToBackgroundWithDelay(final Retryable retryable, final int failedAttempts) {
long exponentialBackoffFactor = (long)Math.min(Math.pow(2.0D, (double)failedAttempts), (double)this.maxBackoffFactor);
long delayMillis = this.initialDelayMillis * exponentialBackoffFactor;
this.backgroundHandler.postDelayed(new Runnable() {
public void run() {
Result result = retryable.run();//RefWatcher.this.ensureGone(reference, watchStartNanoTime)執(zhí)行
if(result == Result.RETRY) {
AndroidWatchExecutor.this.postWaitForIdle(retryable, failedAttempts + 1);
}
}
}, delayMillis);
}
}
這里用到了Handler相關(guān)知識(shí),Looper中的MessageQueue有個(gè)mIdleHandlers隊(duì)列,在獲取下個(gè)要執(zhí)行的Message時(shí),如果沒(méi)有發(fā)現(xiàn)可執(zhí)行的下個(gè)Msg,就會(huì)回調(diào)queueIdle()方法。
Message next() {
int pendingIdleHandlerCount = -1; // -1 only during first iteration
int nextPollTimeoutMillis = 0;
for (;;) {
···
···//省略部分消息查找代碼
if (msg != null) {
if (now < msg.when) {
// Next message is not ready. Set a timeout to wake up when it is ready.
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
···
return msg;
}
} else {
// No more messages.
nextPollTimeoutMillis = -1;
}
// If first time idle, then get the number of idlers to run.
// Idle handles only run if the queue is empty or if the first message
// in the queue (possibly a barrier) is due to be handled in the future.
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}
if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
}
// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler
boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}
if (!keep) {//返回false,則從隊(duì)列移除,下次空閑不會(huì)調(diào)用。
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}
// Reset the idle handler count to 0 so we do not run them again.
pendingIdleHandlerCount = 0;
// While calling an idle handler, a new message could have been delivered
// so go back and look again for a pending message without waiting.
nextPollTimeoutMillis = 0;
}
}
其中的MessageQueue中加入一個(gè)IdleHandler,當(dāng)線程空閑時(shí),就會(huì)去調(diào)用queueIdle()函數(shù),如果返回值為True,那么后續(xù)空閑時(shí)會(huì)繼續(xù)的調(diào)用此函數(shù),否則不再調(diào)用;
知識(shí)點(diǎn)
1,用ActivityLifecycleCallbacks接口來(lái)檢測(cè)Activity生命周期
2,WeakReference + ReferenceQueue 來(lái)監(jiān)聽對(duì)象回收情況
3,Apolication中可通過(guò)processName判斷是否是任務(wù)執(zhí)行進(jìn)程
4,MessageQueue中加入一個(gè)IdleHandler來(lái)得到主線程空閑回調(diào)
5,LeakCanary檢測(cè)只針對(duì)Activiy里的相關(guān)對(duì)象。其他類無(wú)法使用,還得用MAT原始方法
六、總結(jié)
內(nèi)存相關(guān)的問(wèn)題基本問(wèn)題回顧了下,發(fā)現(xiàn)技術(shù)細(xì)節(jié)越扒越多。想要得到技術(shù)的提高,對(duì)這些技術(shù)細(xì)節(jié)的掌握是必要的,只有長(zhǎng)時(shí)間的積累扎實(shí)的技術(shù)細(xì)節(jié)基礎(chǔ),才能讓自己的技術(shù)走的更高。
基礎(chǔ)知識(shí)對(duì)每個(gè)工程師發(fā)展的不同階段意義不同,理解的角度和深度也不同。至少自己來(lái)看,基礎(chǔ)知識(shí)是永遠(yuǎn)值得學(xué)習(xí)和鞏固,來(lái)支撐技術(shù)的創(chuàng)新實(shí)踐。
歡迎轉(zhuǎn)載,請(qǐng)標(biāo)明出處:常興E站 canking.win