標題其實不太恰當,先這樣吧
從一個內存規律說起#
起因是在研究微信小遊戲的時候,發現網上一直流傳著一個規律,即遊戲內數值 * 2 = 內存數值
比如: CE 遊戲內存修改 - 愛神花園
這個兩倍確實有點令人費解,按照我對編譯原理的理解,代碼最後變為匯編代碼的過程中,雖然會經過常量折疊等操作,但代碼中數值是多少,實際內存中的數值也就是那個,比如我們通過 CE 來修改植物大戰殭屍,代碼中並未對陽光數值做加密,因此直接搜索數值就可以定位到:
[re 入門] ce 對植物大戰殭屍的修改
如果是遊戲做了數值的變化還好說,但是實際遊戲代碼裡是並未去做這類操作的(要做這個操作也不至於簡單地做個 2 倍乘法吧🤔)
由此作為因子,開始研究 JS 的內存問題
v8 引擎#
本質上微信小程序代碼也是跑在 v8 引擎上的,於是開始好奇 JS 代碼是怎麼一步步變為匯編代碼的
我以為 node.js 就是 v8,畢竟 node.js 也是基於 v8 移植的嘛,但發現兩者還是有區別的。v8 本身是提供了一個 shell 的,這個才是最原汁原味的
本地可以編譯一個 v8 shell
步驟參考:https://gist.github.com/Becavalier/58da63744f255abe5717e23dacc673e5
# 下載二進制工具
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=/path/depot_tools:$PATH
source ~/.bash_profile
# 新建一個想要安裝v8的目錄
gclient
fetch v8
cd v8
gclient sync
tools/dev/v8gen.py x64.optdebug
ninja -C out.gn/x64.optdebug
# 最後獲得d8的安裝路徑
/path/chromev8/v8/out.gn/x64.optdebug/d8
v8 是怎麼編譯執行 JS 代碼的?#
基本原理還是那套,詞法解析 -> 語法解析 -> 中間代碼生成等
不過 v8 還做了很多優化,即 ignition
與 turbofan
,但這些都不在本次討論範圍內
從圖中可以看到,最後的結果就是 bytecode,那麼這個 bytecode 長什麼樣子?
寫一段代碼做測試:
function test( obj ) {
return obj.money + 10;
}
class UserData {
constructor( money, power ) {
this.money = money;
this.power = power;
}
}
u = new UserData(50, 100);
console.log(u.money, u.power);
for(let i = 0; i < 100000000; i++){
test( u );
}
打印字節碼
d8 -print-bytecode test1.js
我這裡就把完整的字節碼放上來了:
[generated bytecode for function: (0x35e90011ae01 <SharedFunctionInfo>)]
Bytecode length: 131
Parameter count 1
Register count 7
Frame size 56
0x35e90011afec @ 0 : 13 00 LdaConstant [0]
0x35e90011afee @ 2 : c3 Star2
0x35e90011afef @ 3 : 19 fe f7 Mov <closure>, r3
0x35e90011aff2 @ 6 : 65 68 01 f8 02 CallRuntime [DeclareGlobals], r2-r3
0x35e90011aff7 @ 11 : 82 01 CreateBlockContext [1]
0x35e90011aff9 @ 13 : 1a f8 PushContext r2
0x35e90011affb @ 15 : 10 LdaTheHole
0x35e90011affc @ 16 : bf Star6
0x35e90011affd @ 17 : 81 03 00 00 CreateClosure [3], [0], #0
0x35e90011b001 @ 21 : c2 Star3
0x35e90011b002 @ 22 : 13 02 LdaConstant [2]
0x35e90011b004 @ 24 : c1 Star4
0x35e90011b005 @ 25 : 19 f7 f5 Mov r3, r5
0x35e90011b008 @ 28 : 65 2a 00 f6 03 CallRuntime [DefineClass], r4-r6
0x35e90011b00d @ 33 : 1b f8 PopContext r2
0x35e90011b00f @ 35 : 0b f5 Ldar r5
0x35e90011b011 @ 37 : 25 02 StaCurrentContextSlot [2]
0x35e90011b013 @ 39 : 16 02 LdaCurrentContextSlot [2]
0x35e90011b015 @ 41 : c3 Star2
0x35e90011b016 @ 42 : 0d 32 LdaSmi [50]
0x35e90011b018 @ 44 : c2 Star3
0x35e90011b019 @ 45 : 0d 64 LdaSmi [100]
0x35e90011b01b @ 47 : c1 Star4
0x35e90011b01c @ 48 : 0b f8 Ldar r2
0x35e90011b01e @ 50 : 69 f8 f7 02 00 Construct r2, r3-r4, [0]
0x35e90011b023 @ 55 : 23 04 02 StaGlobal [4], [2]
0x35e90011b026 @ 58 : 21 05 04 LdaGlobal [5], [4]
0x35e90011b029 @ 61 : c2 Star3
0x35e90011b02a @ 62 : 2d f7 06 06 GetNamedProperty r3, [6], [6]
0x35e90011b02e @ 66 : c3 Star2
0x35e90011b02f @ 67 : 21 04 08 LdaGlobal [4], [8]
0x35e90011b032 @ 70 : c1 Star4
0x35e90011b033 @ 71 : 2d f6 07 0a GetNamedProperty r4, [7], [10]
0x35e90011b037 @ 75 : c1 Star4
0x35e90011b038 @ 76 : 21 04 08 LdaGlobal [4], [8]
0x35e90011b03b @ 79 : c0 Star5
0x35e90011b03c @ 80 : 2d f5 08 0c GetNamedProperty r5, [8], [12]
0x35e90011b040 @ 84 : c0 Star5
0x35e90011b041 @ 85 : 5f f8 f7 f6 f5 0e CallProperty2 r2, r3, r4, r5, [14]
0x35e90011b047 @ 91 : 0c LdaZero
0x35e90011b048 @ 92 : c4 Star1
0x35e90011b049 @ 93 : 0e LdaUndefined
0x35e90011b04a @ 94 : c5 Star0
0x35e90011b04b @ 95 : 01 0d 00 e1 f5 05 LdaSmi.ExtraWide [100000000]
0x35e90011b051 @ 101 : 6d f9 10 TestLessThan r1, [16]
0x35e90011b054 @ 104 : 9a 18 JumpIfFalse [24] (0x35e90011b06c @ 128)
0x35e90011b056 @ 106 : 21 09 11 LdaGlobal [9], [17]
0x35e90011b059 @ 109 : c3 Star2
0x35e90011b05a @ 110 : 21 04 08 LdaGlobal [4], [8]
0x35e90011b05d @ 113 : c2 Star3
0x35e90011b05e @ 114 : 62 f8 f7 13 CallUndefinedReceiver1 r2, r3, [19]
0x35e90011b062 @ 118 : c5 Star0
0x35e90011b063 @ 119 : 0b f9 Ldar r1
0x35e90011b065 @ 121 : 50 15 Inc [21]
0x35e90011b067 @ 123 : c4 Star1
0x35e90011b068 @ 124 : 8a 1d 00 16 JumpLoop [29], [0], [22] (0x35e90011b04b @ 95)
0x35e90011b06c @ 128 : 0b fa Ldar r0
0x35e90011b06e @ 130 : aa Return
Constant pool (size = 10)
0x35e90011af9d: [FixedArray] in OldSpace
- map: 0x35e900000089 <Map(FIXED_ARRAY_TYPE)>
- length: 10
0: 0x35e90011ae71 <FixedArray[2]>
1: 0x35e90011ae55 <ScopeInfo CLASS_SCOPE>
2: 0x35e90011af79 <FixedArray[7]>
3: 0x35e90011aebd <SharedFunctionInfo UserData>
4: 0x35e900002b85 <String[1]: #u>
5: 0x35e9000044a9 <String[7]: #console>
6: 0x35e900311cad <String[3]: #log>
7: 0x35e90011adc5 <String[5]: #money>
8: 0x35e90011add9 <String[5]: #power>
9: 0x35e900310aed <String[4]: #test>
Handler Table (size = 0)
Source Position Table (size = 0)
[generated bytecode for function: UserData (0x35e90011aebd <SharedFunctionInfo UserData>)]
Bytecode length: 14
Parameter count 3
Register count 0
Frame size 0
0x35e90011b1f8 @ 0 : 0b 03 Ldar a0
0x35e90011b1fa @ 2 : 32 02 00 00 SetNamedProperty <this>, [0], [0]
0x35e90011b1fe @ 6 : 0b 04 Ldar a1
0x35e90011b200 @ 8 : 32 02 01 02 SetNamedProperty <this>, [1], [2]
0x35e90011b204 @ 12 : 0e LdaUndefined
0x35e90011b205 @ 13 : aa Return
Constant pool (size = 2)
0x35e90011b1c9: [FixedArray] in OldSpace
- map: 0x35e900000089 <Map(FIXED_ARRAY_TYPE)>
- length: 2
0: 0x35e90011adc5 <String[5]: #money>
1: 0x35e90011add9 <String[5]: #power>
Handler Table (size = 0)
Source Position Table (size = 0)
50 100
[generated bytecode for function: test (0x35e90011ae81 <SharedFunctionInfo test>)]
Bytecode length: 8
Parameter count 2
Register count 0
Frame size 0
0x35e90011b374 @ 0 : 2d 03 00 01 GetNamedProperty a0, [0], [1]
0x35e90011b378 @ 4 : 44 0a 00 AddSmi [10], [0]
0x35e90011b37b @ 7 : aa Return
Constant pool (size = 1)
0x35e90011b349: [FixedArray] in OldSpace
- map: 0x35e900000089 <Map(FIXED_ARRAY_TYPE)>
- length: 1
0: 0x35e90011adc5 <String[5]: #money>
Handler Table (size = 0)
Source Position Table (size = 0)
對於初次接觸這個字節碼的人來說,肯定是要花點時間才能理解清楚的,所以我們直接丟給chatGPT,看看它能不能復原出原來的代碼:
> 以下回答出自GPT4
這段字節碼表示了一段更複雜的JavaScript代碼。讓我們一步一步地分析它。
### 主函数字节码
#### 常量池:
1. 其他固定數組和元數據
2. ScopeInfo for Class (類的範圍信息)
3. 其他固定數組和元數據
4. `UserData` 的共享函數信息
5. 字符串 `u`
6. 字符串 `console`
7. 字符串 `log`
8. 字符串 `money`
9. 字符串 `power`
10. 字符串 `test`
#### 分析:
1. 定義一個類`UserData`,它有兩個屬性:`money`和`power`。
2. 創建一個`UserData`實例,傳入參數 `50` 和 `100` 作为 `money` 和 `power` 的初始化值。
3. 將這個實例賦值給變量 `u`。
4. 調用 `console.log(u.money, u.power)`。
5. 創建一個循環,循環次數為`100000000`,在循環內部調用函數 `test`,使用 `u` 作為參數。
### UserData構造函数字节码
#### 分析:
1. 設置`this.money`為傳入的第一個參數。
2. 設置`this.power`為傳入的第二個參數。
### test函数字节码
#### 分析:
1. 從傳入的對象中獲取 `money` 屬性。
2. 將獲取到的值加10。
3. 返回這個結果。
### 復原後的JavaScript代碼:
```javascript
class UserData {
constructor(money, power) {
this.money = money;
this.power = power;
}
}
let u = new UserData(50, 100);
console.log(u.money, u.power);
function test(obj) {
return obj.money + 10;
}
for (let i = 0; i < 100000000; i++) {
test(u);
}
這就是根據給定的字節碼復原的 JavaScript 代碼。
GPT4 也太牛了🥹,完美取代我吧
整體的字節碼還是很好理解的,多看幾遍,結合對應的指令格式不難分析出來。
並且可以看到這兩條指令,這就很類似匯編指令裡去 mov 一個數值,所以這個數值一定是原封不動地保留在內存數據裡
0x35e90011b016 @ 42 : 0d 32 LdaSmi [50]
0x35e90011b019 @ 45 : 0d 64 LdaSmi [100]
因為 v8 運行時並沒有 setInterval (也很神奇了),於是直接在 d8 的 shell 裡做這個實驗:
可以很清晰地定位到,內存數值就是實際運行時數值的兩倍(2474 = 1237 * 2)
修改內存,此時的數值就直接發生變化了
對比 node.js#
既然如此,那就來看看 node.js
自定義的類#
同樣的代碼,在 node.js 的 shell 中運行,實例化一個對象 user,可以看到我設置了 user.money 為一個很奇怪的數值,目的是保證內存中不會出現重複的數值(雖然還是重複了三個)
那就將這三個數值都修改了,效果也很明顯,修改完之後,user.money 的數值就變化了
那也不存在什麼兩倍關係啊?🤔 這不是很正常的,定位到數值後直接修改就好了
內置的類#
自定義的類,對應的數值是可以直接搜索到的,那麼 node.js 提供的類會不會不一樣呢,找個 Map 做下測試
class UserData {
constructor(money, power) {
this.test_map =new Map();
this.test_map.set('money', money);
this.test_map.set('power', power);
}
}
通過 CE 還是可以定位到:
並且也能修改:
對比瀏覽器#
後知後覺,才意識到 chrome 的開發者工具中有一個可以拍攝內存快照的工具,主要是用來排查內存泄漏原因的,不過對於內存佈局的研究也很方便
chrome Memory 面板#
我們可以編寫一段示例代碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
</body>
<script>
var _____testArray_____ = [ {value: 'hello'}, {money: 9999} ];
console.info(_____testArray_____);
</script>
</html>
拍攝內存快照,點擊獲取快照即可拍攝下當前的快照
在當前頁面直接按下 cmd + F
(對應 Windows 應該是 ctfl+ F
,可以在底部打開搜索窗口
因為我們的代碼中出現了 hello
字符串,因此直接搜索之,搜到之後,右鍵在摘要視圖中顯示,就可以定位到對應的構造函數了
從內存結構上看,
即:
- hello 字符串 @17037(可以理解為一段內存的標識符)
- 存儲在 Object@92971 (對應
{value: ;'hello'}
)- Obj 又作為數組,存儲在 Array@92961(對應
[ {value: 'hello'} ]
)- 數組存儲在
testArray
這個變量中,由於是全局變量,因此對應到 Window@92867 上
- 數組存儲在
- Obj 又作為數組,存儲在 Array@92961(對應
- 存儲在 Object@92971 (對應
這也對應了通過 var 聲明的變量是直接掛載在 Window 對象的屬性上
什麼是 smi?#
如果你足夠眼尖,應該就能發現,數字 9999 被標記上了 smi number
以下是 GPT4 給出的回答:
在 JavaScript 的 V8 引擎(主要用於 Google Chrome 和 Node.js)中,SMI(Small Integer)是一種特殊的整數表示方式。這種表示方法主要用於性能優化。SMI 是一種 “標記” 整數類型,其中一部分位用於存儲實際的整數值,而其他位則用於標記這個數字為 SMI 類型。
具體的實現可能依賴於具體的平台和環境,但在一個典型的 64 位系統上,一個 SMI 可能會使用 31 位來表示整數值,而剩下的位則用於其他目的,如垃圾收集標記、類型信息等。因為使用了這些額外的位,所以 SMI 能表示的整數範圍會比通常的 32 位或 64 位整數要小。
從 GPT 給出的回答來看,一個 SMI 會使用 31 位來表示整數值,剩餘的 1 位則用於其他目的,那不正好相當於是數據左移了一位!也就是文章開頭提到的內存規律
但還遺留了一個問題,即 node.js 為什麼沒有用 SMI?至少從上述測試來看,node.js 就是直接存的。
GPT 對此也沒給出很好的回答,只能猜測 node.js 背後對 v8 做了一些魔改吧🤣
參考#
- How V8 JavaScript engine works step by step [with diagram] 這篇可以加深對 v8 引擎的理解,有豐富的流程圖
- V8 Internals: How Small is a “Small Integer?” 理解 smi