タイトルは実際にはあまり適切ではありませんが、先に進めましょう。
一つのメモリの法則から始める#
きっかけは、WeChat のミニゲームを研究しているときに、ネット上で常に流れている法則に気づいたことです。つまり、ゲーム内の数値 * 2 = メモリの数値です。
例えば: CE ゲーム内メモリ変更 - 愛神花園
この 2 倍というのは確かに少し理解しにくいです。私のコンパイラ原理の理解によれば、コードが最終的にアセンブリコードに変わる過程では、定数折りたたみなどの操作を経ることはありますが、コード中の数値が何であれ、実際のメモリ中の数値もそのままです。例えば、CE を使って植物対ゾンビを変更する場合、コード中では太陽の数値は暗号化されていないため、直接数値を検索することで特定できます:
[re 入門] ce による植物対ゾンビの変更
もしゲームが数値を変更していたらまだ話は分かりますが、実際のゲームコードではこのような操作は行われていません(この操作を行うにしても、単純に 2 倍の乗算をするわけではないでしょう🤔)。
これをきっかけに、JS のメモリ問題を研究し始めました。
v8 エンジン#
本質的に WeChat のミニプログラムコードも v8 エンジン上で動いているため、JS コードがどのように段階的にアセンブリコードに変わるのかに興味を持ちました。
私は node.js が v8 であると思っていましたが、結局 node.js も v8 を基に移植されたものであることが分かりましたが、両者には違いがあることがわかりました。v8 自体はシェルを提供しており、これが最も純粋なものです。
ローカルで v8 シェルをコンパイルできます。
手順は次のとおりです: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
ですが、これらは今回の議論の範囲外です。
図から見ると、最終的な結果はバイトコードです。では、このバイトコードはどのようなものなのでしょうか?
テスト用にコードを書いてみます:
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 コードを表しています。では、一歩一歩分析してみましょう。
主函数字節コード#
定数プール:#
- その他の固定配列とメタデータ
- クラスのスコープ情報
- その他の固定配列とメタデータ
UserData
の共有関数情報- 文字列
u
- 文字列
console
- 文字列
log
- 文字列
money
- 文字列
power
- 文字列
test
分析:#
UserData
というクラスを定義し、2 つの属性money
とpower
を持っています。UserData
のインスタンスを作成し、50
と100
をmoney
とpower
の初期値として渡します。- このインスタンスを変数
u
に代入します。 console.log(u.money, u.power)
を呼び出します。- ループを作成し、ループ回数は
100000000
、ループ内部で関数test
を呼び出し、u
を引数として使用します。
UserData 構造函数字節コード#
分析:#
this.money
を渡された最初の引数に設定します。this.power
を渡された 2 番目の引数に設定します。
test 函数字節コード#
分析:#
- 渡されたオブジェクトから
money
属性を取得します。 - 取得した値に 10 を加えます。
- この結果を返します。
復元された 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 は本当に素晴らしいですね🥹、完璧に私を置き換えてください。
全体のバイトコードは理解しやすく、何度も見て、対応する命令形式と組み合わせれば、分析するのは難しくありません。
また、これらの 2 つの命令を見ると、アセンブリ命令で数値を mov するのに非常に似ています。したがって、この数値はメモリデータにそのまま保持されていることがわかります。
0x35e90011b016 @ 42 : 0d 32 LdaSmi [50]
0x35e90011b019 @ 45 : 0d 64 LdaSmi [100]
v8 ランタイムには setInterval がないため(これも非常に不思議ですが)、直接 d8 のシェルでこの実験を行いました:
メモリの数値は実際の実行時の数値の 2 倍であることがはっきりと特定できました(2474 = 1237 * 2)。
メモリを変更すると、この時点での数値が直接変化しました。
node.js との比較#
そうであれば、node.js を見てみましょう。
カスタムクラス#
同じコードを node.js のシェルで実行し、オブジェクト user をインスタンス化すると、user.money に非常に奇妙な数値を設定したことがわかります。これは、メモリ内に重複した数値が存在しないことを保証するためです(それでも 3 つの重複がありましたが)。
その 3 つの数値をすべて変更すると、効果は明らかで、変更後に user.money の数値が変化しました。
したがって、2 倍の関係は存在しません🤔。これは非常に正常で、数値を特定した後に直接変更すればよいのです。
組み込みクラス#
カスタムクラスでは、対応する数値を直接検索できますが、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 ビットは他の目的に使用されることがわかります。これは、データが 1 ビット左にシフトされたことに相当します!つまり、記事の冒頭で言及したメモリの法則です。
しかし、1 つの問題が残っています。なぜ node.js は SMI を使用していないのでしょうか?少なくとも上記のテストから見ると、node.js は直接保存しているようです。
GPT はこの点についても良い回答を提供できず、node.js が v8 に対して何らかの魔改造を行っているのではないかと推測するしかありません🤣
参考#
- V8 JavaScript エンジンの動作のステップバイステップ [図付き] この記事は v8 エンジンの理解を深めるのに役立ち、豊富なフローチャートがあります。
- V8 内部:小さな整数はどれくらい小さいのか? SMI を理解するために。