Java 應(yīng)用性能優(yōu)化是一個(gè)老生常談的話題,典型的性能問題如頁面響應(yīng)慢、接口超時(shí),服務(wù)器負(fù)載高、并發(fā)數(shù)低,數(shù)據(jù)庫頻繁死鎖等。尤其是在“糙快猛”的互聯(lián)網(wǎng)開發(fā)模式大行其道的今天,隨著系統(tǒng)訪問量的日益增加和代碼的臃腫,各種性能問題開始紛至沓來。Java 應(yīng)用性能的瓶頸點(diǎn)非常多,比如磁盤、內(nèi)存、網(wǎng)絡(luò) I/O 等系統(tǒng)因素,Java 應(yīng)用代碼,JVM GC,數(shù)據(jù)庫,緩存等。筆者根據(jù)個(gè)人經(jīng)驗(yàn),將 Java 性能優(yōu)化分為 4 個(gè)層級(jí):應(yīng)用層、數(shù)據(jù)庫層、框架層、JVM 層。

每層優(yōu)化難度逐級(jí)增加,涉及的知識(shí)和解決的問題也會(huì)不同。比如應(yīng)用層需要理解代碼邏輯,通過 Java 線程棧定位有問題代碼行等;數(shù)據(jù)庫層面需要分析 SQL、定位死鎖等;框架層需要懂源代碼,理解框架機(jī)制;JVM 層需要對(duì) GC 的類型和工作機(jī)制有深入了解,對(duì)各種 JVM 參數(shù)作用了然于胸。
對(duì)于調(diào)優(yōu)這個(gè)事情來說,一般就是三個(gè)過程:
性能監(jiān)控:?jiǎn)栴}沒有發(fā)生,你并不知道你需要調(diào)優(yōu)什么。此時(shí)需要一些系統(tǒng)、應(yīng)用的監(jiān)控工具來發(fā)現(xiàn)問題。
性能分析:?jiǎn)栴}已經(jīng)發(fā)生,但是你并不知道問題到底出在哪里。此時(shí)就需要使用工具、經(jīng)驗(yàn)對(duì)系統(tǒng)、應(yīng)用進(jìn)行瓶頸分析,以求定位到問題原因。
性能調(diào)優(yōu):經(jīng)過上一步的分析定位到了問題所在,需要對(duì)問題進(jìn)行解決,使用代碼、配置等手段進(jìn)行優(yōu)化。
調(diào)優(yōu)準(zhǔn)備
調(diào)優(yōu)是需要做好準(zhǔn)備工作的,畢竟每一個(gè)應(yīng)用的業(yè)務(wù)目標(biāo)都不盡相同,性能瓶頸也不會(huì)總在同一個(gè)點(diǎn)上。在業(yè)務(wù)應(yīng)用層面,我們需要:
需要了解系統(tǒng)的總體架構(gòu),明確壓力方向。比如系統(tǒng)的哪一個(gè)接口、模塊是使用率最高的,面臨高并發(fā)的挑戰(zhàn)。
需要構(gòu)建測(cè)試環(huán)境來測(cè)試應(yīng)用的性能,使用ab、loadrunner、jmeter都可以。
對(duì)關(guān)鍵業(yè)務(wù)數(shù)據(jù)量進(jìn)行分析,這里主要指的是對(duì)一些數(shù)據(jù)的量化分析,如數(shù)據(jù)庫一天的數(shù)據(jù)量有多少;緩存的數(shù)據(jù)量有多大等
了解系統(tǒng)的響應(yīng)速度、吞吐量、TPS、QPS等指標(biāo)需求,比如秒殺系統(tǒng)對(duì)響應(yīng)速度和QPS的要求是非常高的。
了解系統(tǒng)相關(guān)軟件的版本、模式和參數(shù)等,有時(shí)候限于應(yīng)用依賴服務(wù)的版本、模式等,性能也會(huì)受到一定的影響。
性能分析
性能診斷一種是針對(duì)已經(jīng)確定有性能問題的系統(tǒng)和代碼進(jìn)行診斷,還有一種是對(duì)預(yù)上線系統(tǒng)提前性能測(cè)試,確定性能是否符合上線要求。針對(duì)前者,性能診斷工具主要分為兩層:OS 層面和 Java 應(yīng)用層面(包括應(yīng)用代碼診斷和 GC 診斷),后者可以用各種性能壓測(cè)工具(例如 JMeter)進(jìn)行測(cè)試。
OS 診斷
OS 的診斷主要關(guān)注的是 CPU、Memory、I/O 三個(gè)方面。
CPU 診斷
當(dāng)程序響應(yīng)變慢的時(shí)候,首先使用top、vmstat、ps等命令查看系統(tǒng)的cpu使用率是否有異常,從而可以判斷出是否是cpu繁忙造成的性能問題。其中,主要通過us(用戶進(jìn)程所占的%)這個(gè)數(shù)據(jù)來看異常的進(jìn)程信息。當(dāng)us接近100%甚至更高時(shí),可以確定是cpu繁忙造成的響應(yīng)緩慢。一般說來,cpu繁忙的原因有以下幾個(gè):
線程中有無限空循環(huán)、無阻塞、正則匹配或者單純的計(jì)算
發(fā)生了頻繁的gc
多線程的上下文切換
對(duì)于 CPU 主要關(guān)注平均負(fù)載(Load Average),CPU 使用率,上下文切換次數(shù)(Context Switch)。
通過 top 命令可以查看系統(tǒng)平均負(fù)載和 CPU 使用率,圖為通過 top 命令查看某系統(tǒng)的狀態(tài)。
top -H -p [pid]

平均負(fù)載有三個(gè)數(shù)字:63.66,58.39,57.18,分別表示過去 1 分鐘、5 分鐘、15 分鐘機(jī)器的負(fù)載。按照經(jīng)驗(yàn),若數(shù)值小于 0.7*CPU 個(gè)數(shù),則系統(tǒng)工作正常;若超過這個(gè)值,甚至達(dá)到 CPU 核數(shù)的四五倍,則系統(tǒng)的負(fù)載就明顯偏高。圖中 15 分鐘負(fù)載已經(jīng)高達(dá) 57.18,1 分鐘負(fù)載是 63.66(系統(tǒng)為 16 核),說明系統(tǒng)出現(xiàn)負(fù)載問題,且存在進(jìn)一步升高趨勢(shì),需要定位具體原因了。
確定好cpu使用率最高的進(jìn)程之后就可以使用jstack來打印出異常進(jìn)程的堆棧信息:
jstack [pid]

接下來需要注意的一點(diǎn)是,Linux下所有線程最終還是以輕量級(jí)進(jìn)程的形式存在系統(tǒng)中的,而使用jstack只能打印出進(jìn)程的信息,這些信息里面包含了此進(jìn)程下面所有線程(輕量級(jí)進(jìn)程-LWP)的堆棧信息。因此,進(jìn)一步的需要確定是哪一個(gè)線程耗費(fèi)了大量cpu,此時(shí)可以使用top -p [processId]來查看,也可以直接通過ps -Le來顯示所有進(jìn)程,包括LWP的資源耗費(fèi)信息。最后,通過在jstack的輸出文件中查找對(duì)應(yīng)的lwp的id即可以定位到相應(yīng)的堆棧信息。其中需要注意的是線程的狀態(tài):RUNNABLE、WAITING等。對(duì)于Runnable的進(jìn)程需要注意是否有耗費(fèi)cpu的計(jì)算。對(duì)于Waiting的線程一般是鎖的等待操作。
也可以使用jstat來查看對(duì)應(yīng)進(jìn)程的gc信息,以判斷是否是gc造成了cpu繁忙。
jstat -gcutil [pid]

還可以通過vmstat,通過觀察內(nèi)核狀態(tài)的上下文切換(cs)次數(shù),來判斷是否是上下文切換造成的cpu繁忙:
vmstat 1 5

上下文切換次數(shù)發(fā)生的場(chǎng)景主要有如下幾種:1)時(shí)間片用完,CPU 正常調(diào)度下一個(gè)任務(wù);2)被其它優(yōu)先級(jí)更高的任務(wù)搶占;3)執(zhí)行任務(wù)碰到 I/O 阻塞,掛起當(dāng)前任務(wù),切換到下一個(gè)任務(wù);4)用戶代碼主動(dòng)掛起當(dāng)前任務(wù)讓出 CPU;5)多任務(wù)搶占資源,由于沒有搶到被掛起;6)硬件中斷。Java 線程上下文切換主要來自共享資源的競(jìng)爭(zhēng)。一般單個(gè)對(duì)象加鎖很少成為系統(tǒng)瓶頸,除非鎖粒度過大。但在一個(gè)訪問頻度高,對(duì)多個(gè)對(duì)象連續(xù)加鎖的代碼塊中就可能出現(xiàn)大量上下文切換,成為系統(tǒng)瓶頸。
此外,有時(shí)候可能會(huì)由jit引起一些cpu飚高的情形,如大量方法編譯等。這里可以使用-XX:+PrintCompilation這個(gè)參數(shù)輸出jit編譯情況,以排查jit編譯引起的cpu問題。
內(nèi)存診斷
從操作系統(tǒng)角度,內(nèi)存關(guān)注應(yīng)用進(jìn)程是否足夠,可以使用 free –m 命令查看內(nèi)存的使用情況。通過 top 命令可以查看進(jìn)程使用的虛擬內(nèi)存 VIRT 和物理內(nèi)存 RES,根據(jù)公式 VIRT = SWAP + RES 可以推算出具體應(yīng)用使用的交換分區(qū)(Swap)情況,使用交換分區(qū)過大會(huì)影響 Java 應(yīng)用性能,可以將 swappiness 值調(diào)到盡可能小。因?yàn)閷?duì)于 Java 應(yīng)用來說,占用太多交換分區(qū)可能會(huì)影響性能,畢竟磁盤性能比內(nèi)存慢太多。
對(duì)Java應(yīng)用來說,內(nèi)存主要是由堆外內(nèi)存和堆內(nèi)內(nèi)存組成。
堆外內(nèi)存
堆外內(nèi)存主要是JNI、Deflater/Inflater、DirectByteBuffer(nio中會(huì)用到)使用的。對(duì)于這種堆外內(nèi)存的分析,還是需要先通過vmstat、sar、top、pidstat(這里的sar,pidstat以及iostat都是sysstat軟件套件的一部分,需要單獨(dú)安裝)等查看swap和物理內(nèi)存的消耗狀況再做判斷的。此外,對(duì)于JNI、Deflater這種調(diào)用可以通過Google-preftools來追蹤資源使用狀況。
堆內(nèi)內(nèi)存
此部分內(nèi)存為Java應(yīng)用主要的內(nèi)存區(qū)域。通常與這部分內(nèi)存性能相關(guān)的有:
創(chuàng)建的對(duì)象:這個(gè)是存儲(chǔ)在堆中的,需要控制好對(duì)象的數(shù)量和大小,尤其是大的對(duì)象很容易進(jìn)入老年代
全局集合:全局集合通常是生命周期比較長(zhǎng)的,因此需要特別注意全局集合的使用
緩存:緩存選用的數(shù)據(jù)結(jié)構(gòu)不同,會(huì)很大程序影響內(nèi)存的大小和gc
ClassLoader:主要是動(dòng)態(tài)加載類容易造成永久代內(nèi)存不足
多線程:線程分配會(huì)占用本地內(nèi)存,過多的線程也會(huì)造成內(nèi)存不足
以上使用不當(dāng)很容易造成:
頻繁GC -> Stop the world,使你的應(yīng)用響應(yīng)變慢
OOM,直接造成內(nèi)存溢出錯(cuò)誤使得程序退出。OOM又可以分為以下幾種:
Heap space:堆內(nèi)存不足
PermGen space:永久代內(nèi)存不足
Native thread:本地線程沒有足夠內(nèi)存可分配
排查堆內(nèi)存問題的常用工具是jmap,是jdk自帶的。一些常用用法如下:
查看jvm內(nèi)存使用狀況:jmap -heap
查看jvm內(nèi)存存活的對(duì)象:jmap -histo:live
把heap里所有對(duì)象都dump下來,無論對(duì)象是死是活:jmap -dump:format=b,file=xxx.hprof
先做一次full GC,再dump,只包含仍然存活的對(duì)象信息:jmap -dump:format=b,live,file=xxx.hprof
此外,不管是使用jmap還是在OOM時(shí)產(chǎn)生的dump文件,可以使用Eclipse的MAT(MEMORY ANALYZER TOOL)來分析,可以看到具體的堆棧和內(nèi)存中對(duì)象的信息。當(dāng)然jdk自帶的jhat也能夠查看dump文件(啟動(dòng)web端口供開發(fā)者使用瀏覽器瀏覽堆內(nèi)對(duì)象的信息)。此外,VisualVM也能夠打開hprof文件,使用它的heap walker查看堆內(nèi)存信息。
I/O診斷
I/O 包括磁盤 I/O 和網(wǎng)絡(luò) I/O,一般情況下磁盤更容易出現(xiàn) I/O 瓶頸。通過 iostat 可以查看磁盤的讀寫情況,通過 CPU 的 I/O wait 可以看出磁盤 I/O 是否正常。如果磁盤 I/O 一直處于很高的狀態(tài),說明磁盤太慢或故障,成為了性能瓶頸,需要進(jìn)行應(yīng)用優(yōu)化或者磁盤更換。
文件IO
可以使用系統(tǒng)工具pidstat、iostat、vmstat來查看io的狀況。這里可以看一張使用vmstat的結(jié)果圖。

這里主要注意bi和bo這兩個(gè)值,分別表示塊設(shè)備每秒接收的塊數(shù)量和塊設(shè)備每秒發(fā)送的塊數(shù)量,由此可以判定io繁忙狀況。進(jìn)一步的可以通過使用strace工具定位對(duì)文件io的系統(tǒng)調(diào)用。通常,造成文件io性能差的原因不外乎:
大量的隨機(jī)讀寫
設(shè)備慢
文件太大
網(wǎng)絡(luò)IO
查看網(wǎng)絡(luò)io狀況,一般使用的是netstat工具。可以查看所有連接的狀況、數(shù)目、端口信息等。例如:當(dāng)time_wait或者close_wait連接過多時(shí),會(huì)影響應(yīng)用的相應(yīng)速度。

此外,還可以使用tcpdump來具體分析網(wǎng)絡(luò)io的數(shù)據(jù)。當(dāng)然,tcpdump出的文件直接打開是一堆二進(jìn)制的數(shù)據(jù),可以使用wireshark閱讀具體的連接以及其中數(shù)據(jù)的內(nèi)容。
tcpdump -i eth0 -w tmp.cap -tnn dst port 8080 #監(jiān)聽8080端口的網(wǎng)絡(luò)請(qǐng)求并打印日志到tmp.cap中
還可以通過查看/proc/interrupts來獲取當(dāng)前系統(tǒng)使用的中斷的情況。

各個(gè)列依次是:
irq的序號(hào), 在各自cpu上發(fā)生中斷的次數(shù),可編程中斷控制器,設(shè)備名稱(request_irq的dev_name字段)
通過查看網(wǎng)卡設(shè)備的終端情況可以判斷網(wǎng)絡(luò)io的狀況。
除了常用的 top、 ps、vmstat、iostat 等命令,還有其他 Linux 工具可以診斷系統(tǒng)問題,如 mpstat、tcpdump、netstat、pidstat、sar 等。Brendan 總結(jié)列出了 Linux 不同設(shè)備類型的性能診斷工具,如圖所示,可供參考。

Java 應(yīng)用診斷工具
應(yīng)用代碼診斷
應(yīng)用代碼性能問題是相對(duì)好解決的一類性能問題。通過一些應(yīng)用層面監(jiān)控報(bào)警,如果確定有問題的功能和代碼,直接通過代碼就可以定位;或者通過 top+jstack,找出有問題的線程棧,定位到問題線程的代碼上,也可以發(fā)現(xiàn)問題。對(duì)于更復(fù)雜,邏輯更多的代碼段,通過 Stopwatch 打印性能日志往往也可以定位大多數(shù)應(yīng)用代碼性能問題。
常用的 Java 應(yīng)用診斷包括線程、堆棧、GC 等方面的診斷。
jstack
jstack 命令通常配合 top 使用,通過 top -H -p pid 定位 Java 進(jìn)程和線程,再利用 jstack -l pid 導(dǎo)出線程棧。由于線程棧是瞬態(tài)的,因此需要多次 dump,一般 3 次 dump,一般每次隔 5s 就行。將 top 定位的 Java 線程 pid 轉(zhuǎn)成 16 進(jìn)制,得到 Java 線程棧中的 nid,可以找到對(duì)應(yīng)的問題線程棧。

如上圖所示,其中的線程 24985 運(yùn)行時(shí)間較長(zhǎng),可能存在問題,轉(zhuǎn)成 16 進(jìn)制后,通過 Java 線程棧找到對(duì)應(yīng)線程 0x6199 的棧如下,從而定位問題點(diǎn),如下圖所示。

JProfiler
JProfiler 可對(duì) CPU、堆、內(nèi)存進(jìn)行分析,功能強(qiáng)大,如下圖所示。同時(shí)結(jié)合壓測(cè)工具,可以對(duì)代碼耗時(shí)采樣統(tǒng)計(jì)。

GC 診斷
Java GC 解決了程序員管理內(nèi)存的風(fēng)險(xiǎn),但 GC 引起的應(yīng)用暫停成了另一個(gè)需要解決的問題。JDK 提供了一系列工具來定位 GC 問題,比較常用的有 jstat、jmap,還有第三方工具 MAT 等。
jstat
jstat 命令可打印 GC 詳細(xì)信息,Young GC 和 Full GC 次數(shù),堆信息等。其命令格式為
jstat –gcxxx -t pid ,如下圖所示。

jmap
jmap 打印 Java 進(jìn)程堆信息 jmap –heap pid。通過 jmap –dump:file=xxx pid 可 dump 堆到文件,然后通過其它工具進(jìn)一步分析其堆使用情況
MAT
MAT 是 Java 堆的分析利器,提供了直觀的診斷報(bào)告,內(nèi)置的 OQL 允許對(duì)堆進(jìn)行類 SQL 查詢,功能強(qiáng)大,outgoing reference 和 incoming reference 可以對(duì)對(duì)象引用追根溯源。

上圖是 MAT 使用示例,MAT 有兩列顯示對(duì)象大小,分別是 Shallow size 和 Retained size,前者表示對(duì)象本身占用內(nèi)存的大小,不包含其引用的對(duì)象,后者是對(duì)象自己及其直接或間接引用的對(duì)象的 Shallow size 之和,即該對(duì)象被回收后 GC 釋放的內(nèi)存大小,一般說來關(guān)注后者大小即可。對(duì)于有些大堆 (幾十 G) 的 Java 應(yīng)用,需要較大內(nèi)存才能打開 MAT。通常本地開發(fā)機(jī)內(nèi)存過小,是無法打開的,建議在線下服務(wù)器端安裝圖形環(huán)境和 MAT,遠(yuǎn)程打開查看。或者執(zhí)行 mat 命令生成堆索引,拷貝索引到本地,不過這種方式看到的堆信息有限。
為了診斷 GC 問題,建議在 JVM 參數(shù)中加上-XX:+PrintGCDateStamps。常用的 GC 參數(shù)如下圖所示。

對(duì)于 Java 應(yīng)用,通過 top+jstack+jmap+MAT 可以定位大多數(shù)應(yīng)用和內(nèi)存問題,可謂必備工具。有些時(shí)候,Java 應(yīng)用診斷需要參考 OS 相關(guān)信息,可使用一些更全面的診斷工具,比如 Zabbix(整合了 OS 和 JVM 監(jiān)控)等。在分布式環(huán)境中,分布式跟蹤系統(tǒng)等基礎(chǔ)設(shè)施也對(duì)應(yīng)用性能診斷提供了有力支持。
其他分析工具
上面分別針對(duì)CPU、內(nèi)存以及IO講了一些系統(tǒng)/JDK自帶的分析工具。除此之外,還有一些綜合分析工具或者框架可以更加方便我們對(duì)Java應(yīng)用性能的排查、分析、定位等。
VisualVM
這個(gè)工具應(yīng)該是Java開發(fā)者們非常熟悉的一款java應(yīng)用監(jiān)測(cè)工具,原理是通過jmx接口來連接jvm進(jìn)程,從而能夠看到j(luò)vm上的線程、內(nèi)存、類等信息。
Java Mission Control(jmc)
此工具是jdk7 u40開始自帶的,原來是JRockit上的工具,是一款采樣型的集診斷、分析和監(jiān)控與一體的非常強(qiáng)大的工具:https://docs.oracle.com/javacomponents/jmc-5-5/jmc-user-guide/toc.htm。但是此工具是基于JFR(jcmdJFR.start name=test duration=60s settings=template.jfc filename=output.jfr)的,而開啟JFR需要商業(yè)證書:jcmdVM.unlock_commercial_features。
Btrace
這里不得不提的是btrace這個(gè)神器,它使用java attach api+ java agent + instrument api能夠?qū)崿F(xiàn)jvm的動(dòng)態(tài)追蹤。在不重啟應(yīng)用的情況下可以加入攔截類的方法以打印日志等。具體的用法可以參考Btrace入門到熟練小工完全指南。
Jwebap
Jwebap是一款JavaEE性能檢測(cè)框架,基于asm增強(qiáng)字節(jié)碼實(shí)現(xiàn)。支持:http請(qǐng)求、jdbc連接、method的調(diào)用軌跡跟蹤以及次數(shù)、耗時(shí)的統(tǒng)計(jì)。由此可以獲取最耗時(shí)的請(qǐng)求、方法,并可以查看jdbc連接的次數(shù)、是否關(guān)閉等。但此項(xiàng)目是2006年的一個(gè)項(xiàng)目,已經(jīng)將近10年沒有更新。根據(jù)筆者使用,已經(jīng)不支持jdk7編譯的應(yīng)用。如果要使用,建議基于原項(xiàng)目二次開發(fā),同時(shí)也可以加入對(duì)redis連接的軌跡跟蹤。當(dāng)然,基于字節(jié)碼增強(qiáng)的原理,也可以實(shí)現(xiàn)自己的JavaEE性能監(jiān)測(cè)框架。
性能調(diào)優(yōu)
與性能分析相對(duì)應(yīng),性能調(diào)優(yōu)同樣分為三部分。
CPU調(diào)優(yōu)
不要存在一直運(yùn)行的線程(無限while循環(huán)),可以使用sleep休眠一段時(shí)間。這種情況普遍存在于一些pull方式消費(fèi)數(shù)據(jù)的場(chǎng)景下,當(dāng)一次pull沒有拿到數(shù)據(jù)的時(shí)候建議sleep一下,再做下一次pull。
輪詢的時(shí)候可以使用wait/notify機(jī)制
避免循環(huán)、正則表達(dá)式匹配、計(jì)算過多,包括使用String的format、split、replace方法(可以使用apache的commons-lang里的StringUtils對(duì)應(yīng)的方法),使用正則去判斷郵箱格式(有時(shí)候會(huì)造成死循環(huán))、序列/反序列化等。
結(jié)合jvm和代碼,避免產(chǎn)生頻繁的gc,尤其是full GC。
此外,使用多線程的時(shí)候,還需要注意以下幾點(diǎn):
使用線程池,減少線程數(shù)以及線程的切換
多線程對(duì)于鎖的競(jìng)爭(zhēng)可以考慮減小鎖的粒度(使用ReetrantLock)、拆分鎖(類似ConcurrentHashMap分bucket上鎖), 或者使用CAS、ThreadLocal、不可變對(duì)象等無鎖技術(shù)。此外,多線程代碼的編寫最好使用jdk提供的并發(fā)包、Executors框架以及ForkJoin等,此外Discuptor和Actor在合適的場(chǎng)景也可以使用。
內(nèi)存調(diào)優(yōu)
內(nèi)存的調(diào)優(yōu)主要就是對(duì)jvm的調(diào)優(yōu)。
合理設(shè)置各個(gè)代的大小。避免新生代設(shè)置過小(不夠用,經(jīng)常minor gc并進(jìn)入老年代)以及過大(會(huì)產(chǎn)生碎片),同樣也要避免Survivor設(shè)置過大和過小。
選擇合適的GC策略。需要根據(jù)不同的場(chǎng)景選擇合適的gc策略。這里需要說的是,cms并非全能的。除非特別需要再設(shè)置,畢竟cms的新生代回收策略parnew并非最快的,且cms會(huì)產(chǎn)生碎片。此外,G1直到j(luò)dk8的出現(xiàn)也并沒有得到廣泛應(yīng)用,并不建議使用。
jvm啟動(dòng)參數(shù)配置-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:[log_path],以記錄gc日志,便于排查問題。
其中,對(duì)于第一點(diǎn),具體的還有一點(diǎn)建議:
年輕代大小選擇:響應(yīng)時(shí)間優(yōu)先的應(yīng)用,盡可能設(shè)大,直到接近系統(tǒng)的最低響應(yīng)時(shí)間限制(根據(jù)實(shí)際情況選擇)。在此種情況下,年輕代收集發(fā)生gc的頻率是最小的。同時(shí),也能夠減少到達(dá)年老代的對(duì)象。吞吐量?jī)?yōu)先的應(yīng)用,也盡可能的設(shè)置大,因?yàn)閷?duì)響應(yīng)時(shí)間沒有要求,垃圾收集可以并行進(jìn)行,建議適合8CPU以上的應(yīng)用使用。
年老代大小選擇:響應(yīng)時(shí)間優(yōu)先的應(yīng)用,年老代一般都是使用并發(fā)收集器,所以其大小需要小心設(shè)置,一般要考慮并發(fā)會(huì)話率和會(huì)話持續(xù)時(shí)間等一些參數(shù)。如果堆設(shè)置小了,會(huì)造成內(nèi)存碎片、高回收頻率以及應(yīng)用暫停而使用傳統(tǒng)的標(biāo)記清除方式;如果堆大了,則需要較長(zhǎng)的收集時(shí)間。最優(yōu)化的方案,一般需要參考以下數(shù)據(jù)獲得:
并發(fā)垃圾收集信息
持久代并發(fā)收集次數(shù)
傳統(tǒng)GC信息
花在年輕代和年老代回收上的時(shí)間比例
一般吞吐量?jī)?yōu)先的應(yīng)用都應(yīng)該有一個(gè)很大的年輕代和一個(gè)較小的年老代。這樣可以盡可能回收掉大部分短期對(duì)象,減少中期的對(duì)象,而年老代存放長(zhǎng)期存活對(duì)象。
此外,較小堆引起的碎片問題:因?yàn)槟昀洗牟l(fā)收集器使用標(biāo)記、清除算法,所以不會(huì)對(duì)堆進(jìn)行壓縮。當(dāng)收集器回收時(shí),會(huì)把相鄰的空間進(jìn)行合并,這樣可以分配給較大的對(duì)象。但是,當(dāng)堆空間較小時(shí),運(yùn)行一段時(shí)間以后,就會(huì)出現(xiàn)“碎片”,如果并發(fā)收集器找不到足夠的空間,那么并發(fā)收集器將會(huì)停止,然后使用傳統(tǒng)的標(biāo)記、清除方式進(jìn)行回收。如果出現(xiàn)“碎片”,可能需要進(jìn)行如下配置:-XX:+UseCMSCompactAtFullCollection,使用并發(fā)收集器時(shí),開啟對(duì)年老代的壓縮。同時(shí)使用-XX:CMSFullGCsBeforeCompaction=xx設(shè)置多少次Full GC后,對(duì)年老代進(jìn)行壓縮。
其余對(duì)于jvm的優(yōu)化問題可見后面JVM參數(shù)進(jìn)階一節(jié)。
代碼上,也需要注意:
避免保存重復(fù)的String對(duì)象,同時(shí)也需要小心String.subString()與String.intern()的使用,尤其是后者其底層數(shù)據(jù)結(jié)構(gòu)為StringTable,當(dāng)字符串大量不重復(fù)時(shí),會(huì)使得StringTable非常大(一個(gè)固定大小的hashmap,可以由參數(shù)-XX:StringTableSize=N設(shè)置大小),從而影響young gc的速度。在jackson和fastjson中使用了此方法,某些場(chǎng)景下會(huì)引起gc問題:YGC越來越慢,為什么。
盡量不要使用finalizer
釋放不必要的引用:ThreadLocal使用完記得釋放以防止內(nèi)存泄漏,各種stream使用完也記得close。
使用對(duì)象池避免無節(jié)制創(chuàng)建對(duì)象,造成頻繁gc。但不要隨便使用對(duì)象池,除非像連接池、線程池這種初始化/創(chuàng)建資源消耗較大的場(chǎng)景,
緩存失效算法,可以考慮使用SoftReference、WeakReference保存緩存對(duì)象
謹(jǐn)慎熱部署/加載的使用,尤其是動(dòng)態(tài)加載類等
不要用Log4j輸出文件名、行號(hào),因?yàn)長(zhǎng)og4j通過打印線程堆棧實(shí)現(xiàn),生成大量String。此外,使用log4j時(shí),建議此種經(jīng)典用法,先判斷對(duì)應(yīng)級(jí)別的日志是否打開,再做操作,否則也會(huì)生成大量String。
if (logger.isInfoEnabled()) {
logger.info(msg);
}
IO調(diào)優(yōu)
文件IO上需要注意:
考慮使用異步寫入代替同步寫入,可以借鑒redis的aof機(jī)制。
利用緩存,減少隨機(jī)讀
盡量批量寫入,減少io次數(shù)和尋址
使用數(shù)據(jù)庫代替文件存儲(chǔ)
網(wǎng)絡(luò)IO上需要注意:
和文件IO類似,使用異步IO、多路復(fù)用IO/事件驅(qū)動(dòng)IO代替同步阻塞IO
批量進(jìn)行網(wǎng)絡(luò)IO,減少IO次數(shù)
使用緩存,減少對(duì)網(wǎng)絡(luò)數(shù)據(jù)的讀取
使用協(xié)程:Quasar
其他優(yōu)化建議
算法、邏輯上是程序性能的首要,遇到性能問題,應(yīng)該首先優(yōu)化程序的邏輯處理
優(yōu)先考慮使用返回值而不是異常表示錯(cuò)誤
查看自己的代碼是否對(duì)內(nèi)聯(lián)是友好的:你的Java代碼對(duì)JIT編譯友好么?
此外,jdk7、8在jvm的性能上做了一些增強(qiáng):
通過-XX:+TieredCompilation開啟JDK7的多層編譯(tiered compilation)支持。多層編譯結(jié)合了客戶端C1編譯器和服務(wù)端C2編譯器的優(yōu)點(diǎn)(客戶端編譯能夠快速啟動(dòng)和及時(shí)優(yōu)化,服務(wù)器端編譯可以提供更多的高級(jí)優(yōu)化),是一個(gè)非常高效利用資源的切面方案。在開始時(shí)先進(jìn)行低層次的編譯,同時(shí)收集信息,在后期再進(jìn)一步進(jìn)行高層次的編譯進(jìn)行高級(jí)優(yōu)化。需要注意的一點(diǎn):這個(gè)參數(shù)會(huì)消耗比較多的內(nèi)存資源,因?yàn)橥粋€(gè)方法被編譯了多次,存在多份native內(nèi)存拷貝,建議把code cache調(diào)大一點(diǎn)兒(-XX:+ReservedCodeCacheSize,InitialCodeCacheSize)。否則有可能由于code cache不足,jit編譯的時(shí)候不停的嘗試清理code cache,丟棄無用方法,消耗大量資源在jit線程上。
Compressed Oops:壓縮指針在jdk7中的server模式下已經(jīng)默認(rèn)開啟。
Zero-Based Compressed Ordinary Object Pointers:當(dāng)使用了上述的壓縮指針時(shí),在64位jvm上,會(huì)要求操作系統(tǒng)保留從一個(gè)虛擬地址0開始的內(nèi)存。如果操作系統(tǒng)支持這種請(qǐng)求,那么就開啟了Zero-Based Compressed Oops。這樣可以使得無須在java堆的基地址添加任何地址補(bǔ)充即可把一個(gè)32位對(duì)象的偏移解碼成64位指針。
逃逸分析(Escape Analysis): Server模式的編譯器會(huì)根據(jù)代碼的情況,來判斷相關(guān)對(duì)象的逃逸類型,從而決定是否在堆中分配空間,是否進(jìn)行標(biāo)量替換(在棧上分配原子類型局部變量)。此外,也可以根據(jù)調(diào)用情況來決定是否自動(dòng)消除同步控制,如StringBuffer。這個(gè)特性從Java SE 6u23開始就默認(rèn)開啟。
NUMA Collector Enhancements:這個(gè)重要針對(duì)的是The Parallel Scavenger垃圾回收器。使其能夠利用NUMA (Non Uniform Memory Access,即每一個(gè)處理器核心都有本地內(nèi)存,能夠低延遲、高帶寬訪問) 架構(gòu)的機(jī)器的優(yōu)勢(shì)來更快的進(jìn)行g(shù)c??梢酝ㄟ^-XX:+UseNUMA開啟支持。
此外,網(wǎng)上還有很多過時(shí)的建議,不要再盲目跟隨:
變量用完設(shè)置為null,加快內(nèi)存回收,這種用法大部分情況下并沒有意義。一種情況除外:如果有個(gè)Java方法沒有被JIT編譯但里面仍然有代碼會(huì)執(zhí)行比較長(zhǎng)時(shí)間,那么在那段會(huì)執(zhí)行長(zhǎng)時(shí)間的代碼前顯式將不需要的引用類型局部變量置null是可取的。具體的可以見R大的解釋:https://www.zhihu.com/question/48059457/answer/113538171
方法參數(shù)設(shè)置為final,這種用法也沒有太大的意義,尤其在jdk8中引入了effective final,會(huì)自動(dòng)識(shí)別final變量。
JVM內(nèi)存調(diào)優(yōu)Tips
如何將新對(duì)象預(yù)留在年輕代
眾所周知,由于 Full GC 的成本遠(yuǎn)遠(yuǎn)高于 Minor GC,因此某些情況下需要盡可能將對(duì)象分配在年輕代,這在很多情況下是一個(gè)明智的選擇。雖然在大部分情況下,JVM 會(huì)嘗試在 Eden 區(qū)分配對(duì)象,但是由于空間緊張等問題,很可能不得不將部分年輕對(duì)象提前向年老代壓縮。因此,在 JVM 參數(shù)調(diào)優(yōu)時(shí)可以為應(yīng)用程序分配一個(gè)合理的年輕代空間,以最大限度避免新對(duì)象直接進(jìn)入年老代的情況發(fā)生。
分配足夠大的年輕代空間,使用 JVM 參數(shù)-XX:+PrintGCDetails -Xmx20M -Xms20M-Xmn6M
如何讓大對(duì)象進(jìn)入年老代
我們?cè)诖蟛糠智闆r下都會(huì)選擇將對(duì)象分配在年輕代。但是,對(duì)于占用內(nèi)存較多的大對(duì)象而言,它的選擇可能就不是這樣的。因?yàn)榇髮?duì)象出現(xiàn)在年輕代很可能擾亂年輕代 GC,并破壞年輕代原有的對(duì)象結(jié)構(gòu)。因?yàn)閲L試在年輕代分配大對(duì)象,很可能導(dǎo)致空間不足,為了有足夠的空間容納大對(duì)象,JVM 不得不將年輕代中的年輕對(duì)象挪到年老代。因?yàn)榇髮?duì)象占用空間多,所以可能需要移動(dòng)大量小的年輕對(duì)象進(jìn)入年老代,這對(duì) GC 相當(dāng)不利?;谝陨显颍梢詫⒋髮?duì)象直接分配到年老代,保持年輕代對(duì)象結(jié)構(gòu)的完整性,這樣可以提高 GC 的效率。如果一個(gè)大對(duì)象同時(shí)又是一個(gè)短命的對(duì)象,假設(shè)這種情況出現(xiàn)很頻繁,那對(duì)于 GC 來說會(huì)是一場(chǎng)災(zāi)難。原本應(yīng)該用于存放永久對(duì)象的年老代,被短命的對(duì)象塞滿,這也意味著對(duì)堆空間進(jìn)行了洗牌,擾亂了分代內(nèi)存回收的基本思路。因此,在軟件開發(fā)過程中,應(yīng)該盡可能避免使用短命的大對(duì)象。
可以使用參數(shù)-XX:PetenureSizeThreshold 設(shè)置大對(duì)象直接進(jìn)入年老代的閾值。當(dāng)對(duì)象的大小超過這個(gè)值時(shí),將直接在年老代分配。參數(shù)-XX:PetenureSizeThreshold 只對(duì)串行收集器和年輕代并行收集器有效,并行回收收集器不識(shí)別這個(gè)參數(shù)。
如何設(shè)置對(duì)象進(jìn)入年老代的年齡
堆中的每一個(gè)對(duì)象都有自己的年齡。一般情況下,年輕對(duì)象存放在年輕代,年老對(duì)象存放在年老代。為了做到這點(diǎn),虛擬機(jī)為每個(gè)對(duì)象都維護(hù)一個(gè)年齡。如果對(duì)象在 Eden 區(qū),經(jīng)過一次 GC 后依然存活,則被移動(dòng)到 Survivor 區(qū)中,對(duì)象年齡加 1。以后,如果對(duì)象每經(jīng)過一次 GC 依然存活,則年齡再加 1。當(dāng)對(duì)象年齡達(dá)到閾值時(shí),就移入年老代,成為老年對(duì)象。這個(gè)閾值的最大值可以通過參數(shù)-XX:MaxTenuringThreshold 來設(shè)置,默認(rèn)值是 15。雖然-XX:MaxTenuringThreshold 的值可能是 15 或者更大,但這不意味著新對(duì)象非要達(dá)到這個(gè)年齡才能進(jìn)入年老代。事實(shí)上,對(duì)象實(shí)際進(jìn)入年老代的年齡是虛擬機(jī)在運(yùn)行時(shí)根據(jù)內(nèi)存使用情況動(dòng)態(tài)計(jì)算的,這個(gè)參數(shù)指定的是閾值年齡的最大值。即,實(shí)際晉升年老代年齡等于動(dòng)態(tài)計(jì)算所得的年齡與-XX:MaxTenuringThreshold 中較小的那個(gè)。
參數(shù)為-XX:+PrintGCDetails -Xmx20M -Xms20M -Xmn10M -XX:SurvivorRatio=2 -XX:MaxTenuringThreshold=1
穩(wěn)定的 Java 堆 VS 動(dòng)蕩的 Java 堆
一般來說,穩(wěn)定的堆大小對(duì)垃圾回收是有利的。獲得一個(gè)穩(wěn)定的堆大小的方法是使-Xms 和-Xmx 的大小一致,即最大堆和最小堆 (初始堆) 一樣。如果這樣設(shè)置,系統(tǒng)在運(yùn)行時(shí)堆大小理論上是恒定的,穩(wěn)定的堆空間可以減少 GC 的次數(shù)。因此,很多服務(wù)端應(yīng)用都會(huì)將最大堆和最小堆設(shè)置為相同的數(shù)值。但是,一個(gè)不穩(wěn)定的堆并非毫無用處。穩(wěn)定的堆大小雖然可以減少 GC 次數(shù),但同時(shí)也增加了每次 GC 的時(shí)間。讓堆大小在一個(gè)區(qū)間中震蕩,在系統(tǒng)不需要使用大內(nèi)存時(shí),壓縮堆空間,使 GC 應(yīng)對(duì)一個(gè)較小的堆,可以加快單次 GC 的速度?;谶@樣的考慮,JVM 還提供了兩個(gè)參數(shù)用于壓縮和擴(kuò)展堆空間。
-XX:MinHeapFreeRatio 參數(shù)用來設(shè)置堆空間最小空閑比例,默認(rèn)值是 40。當(dāng)堆空間的空閑內(nèi)存小于這個(gè)數(shù)值時(shí),JVM 便會(huì)擴(kuò)展堆空間。
-XX:MaxHeapFreeRatio 參數(shù)用來設(shè)置堆空間最大空閑比例,默認(rèn)值是 70。當(dāng)堆空間的空閑內(nèi)存大于這個(gè)數(shù)值時(shí),便會(huì)壓縮堆空間,得到一個(gè)較小的堆。
當(dāng)-Xmx 和-Xms 相等時(shí),-XX:MinHeapFreeRatio 和-XX:MaxHeapFreeRatio 兩個(gè)參數(shù)無效。
增大吞吐量提升系統(tǒng)性能
吞吐量?jī)?yōu)先的方案將會(huì)盡可能減少系統(tǒng)執(zhí)行垃圾回收的總時(shí)間,故可以考慮關(guān)注系統(tǒng)吞吐量的并行回收收集器。在擁有高性能的計(jì)算機(jī)上,進(jìn)行吞吐量?jī)?yōu)先優(yōu)化,可以使用參數(shù):
java –Xmx3800m –Xms3800m –Xmn2G –Xss128k –XX:+UseParallelGC
–XX:ParallelGC-Threads=20 –XX:+UseParallelOldGC
–Xmx380m –Xms3800m:設(shè)置 Java 堆的最大值和初始值。一般情況下,為了避免堆內(nèi)存的頻繁震蕩,導(dǎo)致系統(tǒng)性能下降,我們的做法是設(shè)置最大堆等于最小堆。假設(shè)這里把最小堆減少為最大堆的一半,即 1900m,那么 JVM 會(huì)盡可能在 1900MB 堆空間中運(yùn)行,如果這樣,發(fā)生 GC 的可能性就會(huì)比較高;
-Xss128k:減少線程棧的大小,這樣可以使剩余的系統(tǒng)內(nèi)存支持更多的線程;
-Xmn2g:設(shè)置年輕代區(qū)域大小為 2GB;
–XX:+UseParallelGC:年輕代使用并行垃圾回收收集器。這是一個(gè)關(guān)注吞吐量的收集器,可以盡可能地減少 GC 時(shí)間。
–XX:ParallelGC-Threads:設(shè)置用于垃圾回收的線程數(shù),通常情況下,可以設(shè)置和 CPU 數(shù)量相等。但在 CPU 數(shù)量比較多的情況下,設(shè)置相對(duì)較小的數(shù)值也是合理的;
–XX:+UseParallelOldGC:設(shè)置年老代使用并行回收收集器。
嘗試使用大的內(nèi)存分頁
CPU 是通過尋址來訪問內(nèi)存的。32 位 CPU 的尋址寬度是 0~0xFFFFFFFF ,計(jì)算后得到的大小是 4G,也就是說可支持的物理內(nèi)存最大是 4G。但在實(shí)踐過程中,碰到了這樣的問題,程序需要使用 4G 內(nèi)存,而可用物理內(nèi)存小于 4G,導(dǎo)致程序不得不降低內(nèi)存占用。為了解決此類問題,現(xiàn)代 CPU 引入了 MMU(Memory Management Unit 內(nèi)存管理單元)。MMU 的核心思想是利用虛擬地址替代物理地址,即 CPU 尋址時(shí)使用虛址,由 MMU 負(fù)責(zé)將虛址映射為物理地址。MMU 的引入,解決了對(duì)物理內(nèi)存的限制,對(duì)程序來說,就像自己在使用 4G 內(nèi)存一樣。內(nèi)存分頁 (Paging) 是在使用 MMU 的基礎(chǔ)上,提出的一種內(nèi)存管理機(jī)制。它將虛擬地址和物理地址按固定大小(4K)分割成頁 (page) 和頁幀 (page frame),并保證頁與頁幀的大小相同。這種機(jī)制,從數(shù)據(jù)結(jié)構(gòu)上,保證了訪問內(nèi)存的高效,并使 OS 能支持非連續(xù)性的內(nèi)存分配。在程序內(nèi)存不夠用時(shí),還可以將不常用的物理內(nèi)存頁轉(zhuǎn)移到其他存儲(chǔ)設(shè)備上,比如磁盤,這就是大家耳熟能詳?shù)奶摂M內(nèi)存。
在 Solaris 系統(tǒng)中,JVM 可以支持 Large Page Size 的使用。使用大的內(nèi)存分頁可以增強(qiáng) CPU 的內(nèi)存尋址能力,從而提升系統(tǒng)的性能。
java –Xmx2506m –Xms2506m –Xmn1536m –Xss128k –XX:++UseParallelGC
–XX:ParallelGCThreads=20 –XX:+UseParallelOldGC –XX:+LargePageSizeInBytes=256m
–XX:+LargePageSizeInBytes:設(shè)置大頁的大小。
過大的內(nèi)存分頁會(huì)導(dǎo)致 JVM 在計(jì)算 Heap 內(nèi)部分區(qū)(perm, new, old)內(nèi)存占用比例時(shí),會(huì)出現(xiàn)超出正常值的劃分,最壞情況下某個(gè)區(qū)會(huì)多占用一個(gè)頁的大小。
使用非占有的垃圾回收器
為降低應(yīng)用軟件的垃圾回收時(shí)的停頓,首先考慮的是使用關(guān)注系統(tǒng)停頓的 CMS 回收器,其次,為了減少 Full GC 次數(shù),應(yīng)盡可能將對(duì)象預(yù)留在年輕代,因?yàn)槟贻p代 Minor GC 的成本遠(yuǎn)遠(yuǎn)小于年老代的 Full GC。
java –Xmx3550m –Xms3550m –Xmn2g –Xss128k –XX:ParallelGCThreads=20
–XX:+UseConcMarkSweepGC –XX:+UseParNewGC –XX:+SurvivorRatio=8 –XX:TargetSurvivorRatio=90
–XX:MaxTenuringThreshold=31
–XX:ParallelGCThreads=20:設(shè)置 20 個(gè)線程進(jìn)行垃圾回收;
–XX:+UseParNewGC:年輕代使用并行回收器;
–XX:+UseConcMarkSweepGC:年老代使用 CMS 收集器降低停頓;
–XX:+SurvivorRatio:設(shè)置 Eden 區(qū)和 Survivor 區(qū)的比例為 8:1。稍大的 Survivor 空間可以提高在年輕代回收生命周期較短的對(duì)象的可能性,如果 Survivor 不夠大,一些短命的對(duì)象可能直接進(jìn)入年老代,這對(duì)系統(tǒng)來說是不利的。
–XX:TargetSurvivorRatio=90:設(shè)置 Survivor 區(qū)的可使用率。這里設(shè)置為 90%,則允許 90%的 Survivor 空間被使用。默認(rèn)值是 50%。故該設(shè)置提高了 Survivor 區(qū)的使用率。當(dāng)存放的對(duì)象超過這個(gè)百分比,則對(duì)象會(huì)向年老代壓縮。因此,這個(gè)選項(xiàng)更有助于將對(duì)象留在年輕代。
–XX:MaxTenuringThreshold:設(shè)置年輕對(duì)象晉升到年老代的年齡。默認(rèn)值是 15 次,即對(duì)象經(jīng)過 15 次 Minor GC 依然存活,則進(jìn)入年老代。這里設(shè)置為 31,目的是讓對(duì)象盡可能地保存在年輕代區(qū)域。
總結(jié)與建議
性能調(diào)優(yōu)同樣遵循 2-8 原則,80%的性能問題是由 20%的代碼產(chǎn)生的,因此優(yōu)化關(guān)鍵代碼事半功倍。同時(shí),對(duì)性能的優(yōu)化要做到按需優(yōu)化,過度優(yōu)化可能引入更多問題。對(duì)于 Java 性能優(yōu)化,不僅要理解系統(tǒng)架構(gòu)、應(yīng)用代碼,同樣需要關(guān)注 JVM 層甚至操作系統(tǒng)底層??偨Y(jié)起來主要可以從以下幾點(diǎn)進(jìn)行考慮:
1)基礎(chǔ)性能的調(diào)優(yōu)
這里的基礎(chǔ)性能指的是硬件層級(jí)或者操作系統(tǒng)層級(jí)的升級(jí)優(yōu)化,比如網(wǎng)絡(luò)調(diào)優(yōu),操作系統(tǒng)版本升級(jí),硬件設(shè)備優(yōu)化等。比如 F5 的使用和 SDD 硬盤的引入,包括新版本 Linux 在 NIO 方面的升級(jí),都可以極大的促進(jìn)應(yīng)用的性能提升;
2)數(shù)據(jù)庫性能優(yōu)化
包括常見的事務(wù)拆分,索引調(diào)優(yōu),SQL 優(yōu)化,NoSQL 引入等,比如在事務(wù)拆分時(shí)引入異步化處理,最終達(dá)到一致性等做法的引入,包括在針對(duì)具體場(chǎng)景引入的各類 NoSQL 數(shù)據(jù)庫,都可以大大緩解傳統(tǒng)數(shù)據(jù)庫在高并發(fā)下的不足;
3)應(yīng)用架構(gòu)優(yōu)化
引入一些新的計(jì)算或者存儲(chǔ)框架,利用新特性解決原有集群計(jì)算性能瓶頸等;或者引入分布式策略,在計(jì)算和存儲(chǔ)進(jìn)行水平化,包括提前計(jì)算預(yù)處理等,利用典型的空間換時(shí)間的做法等;都可以在一定程度上降低系統(tǒng)負(fù)載;
4)業(yè)務(wù)層面的優(yōu)化
技術(shù)并不是提升系統(tǒng)性能的唯一手段,在很多出現(xiàn)性能問題的場(chǎng)景中,其實(shí)可以看到很大一部分都是因?yàn)樘厥獾臉I(yè)務(wù)場(chǎng)景引起的,如果能在業(yè)務(wù)上進(jìn)行規(guī)避或者調(diào)整,其實(shí)往往是最有效的。
參考
Java 應(yīng)用性能調(diào)優(yōu)實(shí)踐