v8内存管理

# 背景

# V8内存垃圾回收涉及

  • 内存
  • V8
  • 垃圾回收

# 计算机模型

img

# 寄存器

  • 是中央处理器的组成部分
  • 寄存器是有限存储容量的高速存储部件
  • 可以用来暂存指令、数据和地址
  • 寄存器内的数据可用于执行算术和逻辑运算
  • 寄存器内的地址可用于指向内存的某个位置

# 内存

# 简介

  • 随机存取存储器(Random Access Memory)也叫内存,英文缩写RAM
  • RAM是与CPU直接交换数据的内部存储器
  • RAM工作时可以从任何一个指定地址写入或读出信息
  • RAM用来在计算机中用来暂时存储程序、数据和中间结果

img

console.log(0xFFFFFFFF);//4294967295
console.log(0xFFFFFFFF / 1024 / 1024 / 1024);
1
2

# 内存空间分类

  • 内存空间

    会分为二类

    • 数据空间
    • 指令空间

img

# 指令

  • 可以通过指令指挥命令计算机进行工作

# 机器语言指令

  • 计算机只认识0和1,所以我们可以通过二进制指令和计算机进行沟通
  • 这些指令被称为指令集,也就是机器语言
  • MIPS是一种采取精简指令集(RISC)的处理器架构
  • 最常见的MIPS-32位指令集每个指令是一个32位的二进制数

img

# 汇编指令

部分 含义
opcode操作码 0 add做加法运算
rs $s1 第一个来源寄存器
rt $s2 第二个来源寄存器
rd $s3 目标寄存器
shamt 0 位移量
funct 32 函数,这个字段选择Opcode操作某个特定变体
助记符 opcode rs rt rd shamt funct 示例 示例含义 操作
add 000000 rs rt rd 00000 100000 add $1,$2,$3 $1=$2+$3 带符号数相加
addu 000000 rs rt rd 00000 100001 add $1,$2,$3 $1=$2+$3 无符号数相加
sll 000000 00000 rt rd sa 000000 sll rd,rt,sa rd = rt << sa 逻辑左移,寄存器rt的值向左移sa位,结果保存到rd寄存器中
add $s3,$s1,$s2
addi $s1,$zero,1
sll s2,s1,1
console.log((0x02329820).toString('2'));
1
2
3
4

# 程序指针

  • PC寄存器存储着下一条要执行的指令的内存地址
  • 机器循环=PC位置获取指令->分析指令->执行指令->PC指针移动
  • 执行过程 (opens new window)

img

汇编指令 含义
addi $s1,$zero,1 把$zero中的值加上数字1保存到寄存器$1中
addi $s2,$zero,2 把$zero中的值加上数字2保存到寄存器$1中
add $s3,$s1,$s2 把寄存器$s1和寄存器$2的和存储到$s3中

# V8

# 语言的分类

# 解释执行

  • 先将源代码通过解析器转成中间代码,再用解释器执行中间代码,输出结果
  • 启动快,执行慢

img

# 编译执行

  • 先将源代码通过解析器转成中间代码,再用编译器把中间代码转成机器码,最后执行机器码,输出结果
  • 启动慢,执行快

img

# V8执行过程

  • V8采用的是解释和编译两种方式,这种混合使用的方式称为JIT技术
  • 第一步先由解析器生成抽象语法树和相关的作用域
  • 第二步根据AST和作用域生成字节码,字节码是介于AST和机器码的中间代码
  • 然后由解释器直接执行字节码,也可以让编译器把字节码编译成机器码后再执行
  • jsvu (opens new window)可以快速安装V8引擎
  • V8源码编译出来的可执行程序名为d8d8 (opens new window),d8是V8自己的开发工具shell

img

# 抽象语法树

img

var a = 1;
var b = 2;
var c = a + b;
d8 --print-ast 4.js
[generating bytecode for function: ]
--- AST ---
FUNC at 0
. KIND 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . VARIABLE (00000278B965EB88) (mode = VAR) "a"
. . VARIABLE (00000278B965EC78) (mode = VAR) "b"
. . VARIABLE (00000278B965EDB0) (mode = VAR) "c"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 8
. . . INIT at 8
. . . . VAR PROXY unallocated (00000278B965EB88) (mode = VAR) "a"
. . . . LITERAL 1
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 20
. . . INIT at 20
. . . . VAR PROXY unallocated (00000278B965EC78) (mode = VAR) "b"
. . . . LITERAL 2
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 32
. . . INIT at 32
. . . . VAR PROXY unallocated (00000278B965EDB0) (mode = VAR) "c"
. . . . ADD at 34
. . . . . VAR PROXY unallocated (00000278B965EB88) (mode = VAR) "a"
. . . . . VAR PROXY unallocated (00000278B965EC78) (mode = VAR) "b"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

img

# 作用域

  • 作用域是一个抽象的概念,它描述了一个变量的生命周期,比如哪些变量是在哪里声明的,哪些变量是在哪里使用的
d8 --print-scopes 1.js
Global scope:
global { // (000001D42188BEC0) (0, 38)
  // will be compiled
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (000001D42188C730) local[0]
  // local vars:
  VAR c;  // (000001D42188C670)
  VAR b;  // (000001D42188C538)
  VAR a;  // (000001D42188C448)
}
1
2
3
4
5
6
7
8
9
10
11
12

# 3.2.3 字节码

var a = 10;
var b = 20;
var c = a + b;
d8 --print-bytecode  4.js
LdaConstant [0] 从常量池中加载索引0的常量到累加寄存器中
Star r1 把累加器的值保存到目标寄存器中
LdaZero 把0保存到累加寄存器中
Star r2 把累加器的值0保存到目标寄存器中
Mov <closure>, r3 保存r3寄存器的值
CallRuntime [DeclareGlobals], r1-r3

StackCheck    检查栈是否溢出

LdaSmi [10]   加载10到累加寄存器中
StaGlobal [1] 把累加寄存器的值保存到常量池索引1处

LdaSmi [20]   加载20到累加寄存器中
StaGlobal [2] 把累加寄存器的值保存到常量池索引2处

LdaGlobal [1] 从常量池加载索引1到累加寄存器
Star r1       把累加器的值10保存到目标计数器中
LdaGlobal [2] 从常量池加载索引2的值20到累加寄存器
Add r1  把r1寄存器的值加到累加寄存器中,累加寄存器值为30
StaGlobal [3] 把累加寄存器的值保存到常量池索引3处
LdaUndefined  把Undefined保存到累加寄存器中
Return        返回累加寄存器中的值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 3.2.4 编译器优化

function sum() {
    let a = 1;
    let b = 2;
    return a + b;
}
for (let i = 0; i < 10000; i++) {
    sum();
}
d8 --trace-opt sum.js
[marking 0x02ccc2ba2279 <JSFunction (sfi = 000002CCC2BA2091)> for optimized recompilation, reason: small function, ICs with typeinfo: 4/4 (100%), generic ICs: 0/4 (0%)]
[marking 0x02ccc2ba2339 <JSFunction sum (sfi = 000002CCC2BA2141)> for optimized recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[compiling method 0x02ccc2ba2339 <JSFunction sum (sfi = 000002CCC2BA2141)> using TurboFan]
1
2
3
4
5
6
7
8
9
10
11
12

# 内存管理

  • 程序运行需要分配内存
  • V8也会申请内存,申请的内存又会分为堆内存和栈内存

#

  • 栈用于存放JS中的基本类型和引用类型指针
  • 栈的空间是连续的,增加删除只需要移动指针,操作速度非常快
  • 栈的空间是有限的,当栈满了,就会抛出一个错误
  • 栈一般是在执行函数时创建的,在函数执行完毕后,栈就会被销毁

#

  • 如果不需要连续空间,或者申请的内存较大,可以使用堆
  • 堆主要用于存储JS中的引用类型
  • 源码 (opens new window)

image-20220216205349847

const v8 = require('v8');
const heapSpace = v8.getHeapSpaceStatistics();
function format(size) {
    return `${(size / 1024 / 1024).toFixed(2)}M`.padEnd(10, ' ');
}
console.log(`${"空间名称".padEnd(20, ' ')} 空间大小 已用空间大小 可用空间大小 物理空间大小`);
for (let i = 0; i < heapSpace.length; i++) {
    const space = heapSpace[i];
    console.log(`${space.space_name.padEnd(23, ' ')}`,
        `${format(space.space_size)}`,
        `${format(space.space_used_size)}`,
        `${format(space.space_available_size)}`,
        `${format(space.physical_space_size)}`);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 堆空间分类

# 新生代(new space)
  • 新生代内存用于存放一些生命周期比较短的对象数据
# 老生代(old space)
  • 老生代 (opens new window)内存用于存放一些生命周期比较长的对象数据
  • new space的对象进行两个周期的垃圾回收后,如果数据还存在new space中,则将他们存放到old space
  • old space又可以分为两部分,分别是Old pointer space和Old data space
    • Old pointer space 存放GC后surviving的指针对象
    • Old data space 存放GC后surviving的数据对象
  • Old Space使用标记清除和标记整理的方式进行垃圾回收
# Code space
  • 用于存放JIT已编译的代码
  • 唯一拥有执行权限的内存
# Large object space
  • 为了避免大对象的拷贝,使用该空间专门存储大对象
  • GC 不会回收这部分内存
# Map space
  • 存放对象的Map信息,即隐藏类
  • 隐藏类是为了提升对象属性的访问速度的
  • V8 会为每个对象创建一个隐藏类,记录了对象的属性布局,包括所有的属性和偏移量

# 什么是垃圾

  • 在程序运行过程中肯定会用到一些数据,这些数据会放在堆栈中,但是在程序运行结束后,这些数据就不会再被使用了,那些不再使用的数据就是垃圾
global.a = { name: 'a' };
global.a.b = { name: 'b1' };
global.a.b = { name: 'b2' };
1
2
3

image-20220216205552120

# 新生代的垃圾回收

  • 新生代内存有两个区域,分别是对象区域(from) 和 空闲区域(to)
  • 新生代内存使用Scavenger算法来管理内存,垃圾回收的入口
    • 广度优先遍历 From-Space 中的对象,从根对象出发,广度优先遍历所有能到达的对象,把存活的对象复制到 To-Space
    • 遍历完成后,清空 From-Space
    • From-Space 和 To-Space 角色互换
  • 复制后的对象在 To-Space 中占用的内存空间是连续的,不会出现碎片问题
  • 这种垃圾回收方式快速而又高效,但是会造成空间浪费
  • 新生代的 GC 比较频繁
  • 新生代的对象转移到老生代称为晋升Promote,判断晋升的情况有两种
    • 经过一次 GC 还存活的对象
    • 对象复制到 To-Space 时,To-Space 的空间达到一定的限制
global.a={};
global.b = {e:{}}
global.c = {f: {},g:{h:{}}} 
global.d = {};
global.d=null;
1
2
3
4
5

广度优先遍历过程 (opens new window)

bool Heap::ShouldBePromoted(Address old_address) {
  Page* page = Page::FromAddress(old_address);
  Address age_mark = new_space_->age_mark();
  return page->IsFlagSet(MemoryChunk::NEW_SPACE_BELOW_AGE_MARK) &&
         (!page->ContainsLimit(age_mark) || old_address < age_mark);
}
1
2
3
4
5
6

# 老生代的垃圾回收

  • 老生代里的对象有些是从新生代晋升过来的,有些是比较大的对象直接分配到老生代里的,所以老生代的对象空间大,活的长
  • 如果使用Scavenge算法,浪费一半空间不说,复制如此大块的内存消耗时间将会相当长。所以Scavenge算法显然不适合
  • V8在老生代中的垃圾回收策略采用Mark-Sweep(标记清除)和Mark-Compact(标记整理)相结合
# Mark-Sweep(标记清除)
  • 标记清除分为标记和清除两个阶段
  • 在标记阶段需要遍历堆中的所有对象,并标记那些活着的对象,然后进入清除阶段。在清除阶段总,只清除没有被标记的对象
  • V8采取的是黑色和白色来标记数据,垃圾收集之前,会把所有的数据设置为白色,用来标记所有的尚未标记的对象,然后会从GC根出发,以深度优先的方式把所有的能访问到的数据都标记为黑色,遍历结束后黑色的就是活的数据,白色的就是可以清理的垃圾数据
  • 由于标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,所以效率较高
  • 标记清除有一个问题就是进行一次标记清楚后,内存空间往往是不连续的,会出现很多的内存碎片。如果后续需要分配一个需要内存空间较多的对象时,如果所有的内存碎片都不够用,就会出现内存溢出的问题

深度优先遍历过程 (opens new window)

# Mark-Compact(标记整理)
  • 标记整理正是为了解决标记清除所带来的内存碎片的问题
  • 标记整理在标记清除的基础进行修改,将其的清除阶段变为紧缩极端
  • 在整理的过程中,将活着的对象向内存区的一段移动,移动完成后直接清理掉边界外的内存
  • 紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片,一般10次标记清理会伴随一次标记整理

深度优先遍历过程 (opens new window)

# 优化

  • 在执行垃圾回收算法期间,JS脚本需要暂停,这种叫Stop the world(全停顿)
  • 如果回收时间过长,会引起卡顿
  • 性能优化
    • 把大任务拆分小任务,分步执行,类似fiber
    • 将一些任务放在后台执行,不占用主线程
JavaScript执行 垃圾标记、垃圾清理、垃圾整理 JavaScript执行
--------------                          ---------------->
1
2
# Parallel(并行执行)
  • 新生代的垃圾回收采取并行策略提升垃圾回收速度,它会开启多个辅助线程来执行新生代的垃圾回收工作
  • 并行执行需要的时间等于所有的辅助线程时间的总和加上管理的时间
  • 并行执行的时候也是全停顿的状态,主线程不能进行任何操作,只能等待辅助线程的完成
  • 这个主要应用于新生代的垃圾回收
         -------辅助线程----->
         -------辅助线程----->
         -------辅助线程----->
---------                    --------------------------->
1
2
3
4
# 增量标记
  • 老生代因为对象又大又多,所以垃圾回收的时间更长,采用增量标记的方式进行优化
  • 增量标记就是把标记工作分成多个阶段,每个阶段都只标记一部分对象,和主线程的执行穿插进行
  • 为了支持增量标记,V8必须可以支持垃圾回收的暂停和恢复,所以采用了黑白灰三色标记法
    • 黑色表示这个节点被GC根引用到了,而且该节点的子节点都已经标记完成了
    • 灰色表示这个节点被 GC根引用到了,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点
    • 白色表示此节点还没未被垃圾回收器发现,如果在本轮遍历结束时还是白色,那么这块数据就会被收回
  • 引入了灰色标记后,就可以通过判断有没有灰色节点来判断标记是否完成了,如果有灰色节点,下次恢复的应该从灰色节点继续执行

增量标记 (opens new window)

---------开始标记---增量标记---增量标记---清理---整理----------------->
1
# Write-barrier(写屏障)
  • 当黑色指向白色节点的时候,就会触发写屏障,这个写屏障会把白色节点设置为灰色
global.a = { name: 'a' };
global.a.b = { name: 'b1' };
//执行标记工作
global.a.b = { name: 'b2' };
//继续执行标记工作
1
2
3
4
5

Write-barrier(写屏障) (opens new window)

# Lazy Sweeping(惰性清理)
  • 当增量标记完成后,如果内存够用,先不清理,等JS代码执行完慢慢清理
# concurrent(并发回收)
  • 其实增量标记和惰性清理并没有减少暂停的总时间
  • 并发回收就是主线程在执行过程中,辅助线程可以在后台完成垃圾回收工作
  • 标记操作全都由辅助线程完,清理操作由主线程和辅助线程配合完成
         ----辅助线程标记---->      -----清理整理---->
         ----辅助线程标记---->      -----清理整理---->
         ----辅助线程标记---->      -----清理整理---->
-----------------------------执行JS>-----清理整理--->--------------------------->
1
2
3
4
# 并发(concurrent)和并行(parallel)
  • 并发和并行都是同时执行任务
  • 并行的同时是同一时刻可以多个进程在运行
  • 并发的同时是经过上下文快速切换,使得看上去多个进程同时都在运行的现象

# 内存泄露

# 什么是内存泄露

  • 那当不再用到的对象内存没有及时被回收时,我们叫它内存泄漏

# 不合理的闭包

  • 闭包 (opens new window)
  • 一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起这样的组合就是闭包(closure)
  • 也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来
  • 词法(lexical)一词指的是,词法作用域根据源代码中声明变量的位置来确定该变量在何处可用。嵌套函数可访问声明于它们外部作用域的变量
function Person(){}
function fn(){
    let name = 'zhufeng';
    let age = 13;
    let arr = new Array();
    for(let i=0;i<10000;i++){
        arr.push(new Person());
    }
    return function(){
        console.log('hello',arr,name);
    }
}
let hello = fn();
debugger
hello();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 隐式全局变量

  • 全局变量通常不会被回收,所以要避免额外的全局变量
  • 使用完毕后经重置值为null
function Person(){}
function fn(){
    p1 = new Person();
    this.p2 = new Person();
}
fn();
p1=null;
p2=null;
1
2
3
4
5
6
7
8

# 分离的DOM

  • 当在界面中移除DOM节点时,还要移除相应的节点引用
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="container">
        <p id="title"></p>
    </div>
    <script>
       var container = document.getElementById('container');
       var title = document.getElementById('title');
       document.body.removeChild(container);
       //Detached  HTMLParagraphElement
       container=null;
       title=null;
    </script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 定时器

  • setInterval(fn,delay) cleanInterval(id)
  • setTimeout(fn,delay) cleanTimeout(id)
  • setImmediate(fn) cleanImmediate(id)
  • requestAnimationFrame(fn) cleanRequestAnimationFrame(id)
<script>
 function Person(){}
 function fn(){
    var p1 = new Person();
    var id = setInterval(()=>{
        p1.age = 20;
    },1000);
    clearInterval(id);
 }
 fn();
</script>
1
2
3
4
5
6
7
8
9
10
11

# 事件监听器

  • 监听函数如果不及时移除,会导致内存泄漏
var data = new Array(100000);
class App extends React.Component{
    componentDidMount(){
        document.addEventListener('click',this.handleClick);
    }
    handleClick=()=>{
        console.log('click',data);
    }
    componentWillUnmount(){
        document.removeEventListener('click',this.handleClick);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

# Map、Set对象

  • Map 或 Set 存储对象时如果不主动清除也会造成内存不自动回收
  • 可以采用 WeakMap,WeakSet 对象同样用来保存键值对,对于键是弱引用(WeakMap 只对于键是弱引用),且必须为一个对象,而值可以是任意的对象或者原始值,由于是对于对象的弱引用,不会阻止 垃圾回收
function Person(){}
let obj = new Person();
let set = new Set([obj]);
let map = new Map([[obj,'zhufeng']]);
obj = null;
function Person(){}
let obj = new Person();
let set = new WeakSet([obj]);
let map = new WeakMap([[obj,'zhufeng']]);
obj = null;
1
2
3
4
5
6
7
8
9
10

# console

  • 浏览器保存了我们输出对象的信息数据引用
  • 未清理的 console 如果输出了对象也会造成内存泄漏
function Person(){}
function fn(){
    let name = 'zhufeng';
    let age = 13;
    let arr = new Array();
    for(let i=0;i<10000;i++){
        arr.push(new Person());
    }
    return function(){
        console.log('hello',arr);
    }
}
let hello = fn();
hello();
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 内存泄漏排查

  • # 发现内存泄漏

    <body>
      <div id="container">0</div>
      <button id="click">click</button>
      <script>
          var rows = [];
          function Person() { }
          function getColumns() {
              var columns = new Array(10000).fill('0');
              for (let i = 0; i < columns.length; i++) {
                  columns[i] = new Person();
              }
              return function () {
                  return columns;
              }
          }
          click.addEventListener("click", function () {
              rows.push(getColumns());
              container.innerHTML = rows.length;
          });
      </script>
    </body>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21

# 定位内存泄漏

# 录制监控

  • 刷新录制页面加载
  • 监控堆、文档 、节点、监听器、CPU
  • 手工GC

# 内存快照

字段 含义
摘要 按构造函数进行分组,捕获对象和其使用内存的情况
对比 对比某个操作前后的内存快照区别
控制 查看堆的具体内容,可以用来查看对象结构
统计信息 统计视图

# 摘要

字段 含义
构造函数 显示所有的构造函数,点击每一个构造函数可以查看由该构造函数创建的所有对象
距离 显示通过最短的节点路径到根节点的距离,引用层级
浅层大小 显示对象所占内存,不包含内部引用的其他对象所占的内存
保留的大小 显示对象所占的总内存,包含内部引用的其他对象所占的内存

# 对比

字段 含义
新对象数 新建对象数
已删除项 回收对象数
增量 新建对象数减去回收的对象数

# 性能优化

# 少用全局变量

  • 全局执行上下文会一直存在于上下文执行栈中,不会销毁,容易内存泄露
  • 查找变量的链条比较长,比较消耗性能
  • 容易引起命名冲突
  • 确定需要使用的全局就是可以局部缓存
var a = 'a';
function one() {
    return function two() {
        return function three() {
            let b = a;
            for (let i = 0; i < 100000; i++) {
                console.log(b);
            }
        }
    }
}
one()()();
1
2
3
4
5
6
7
8
9
10
11
12

# 通过原型新增方法

var Person = function () {
    this.getName = function () {
        console.log('person');
    }
}
let p1 = new Person();
var Person = function () {

}
Person.prototype.getName = function () {
    console.log('person');
}
let p1 = new Person();
1
2
3
4
5
6
7
8
9
10
11
12
13

# 尽量创建对象一次搞定

  • V8会为每个对象分配一个隐藏类,如果对象结构发生改变就会重建隐藏类,结构相同的对象会共且隐藏类
  • 隐藏类描述了对象的结构和属性偏移地址,可以加速查找属性的时间
  • 优化指南
    • 创建对象尽量保持属性顺序一致
    • 尽量不要动态添加和删除属性
d8 --allow-natives-syntax main.js 
let p1 = {name:'zhangsan',age:10}
let p2 = {age:10,name:'zhangsan'}

let p = {};
%DebugPrint(point);
p.name = 'wangwu';
%DebugPrint(point);
p.age = 10;
%DebugPrint(point);
1
2
3
4
5
6
7
8
9
10

# 尽量保持参数结构稳定

  • V8中的内联缓存会监听函数执行,记录中间数据,参数结构不同会让优化失效
function read(obj){
 console.log(obj.toString());
}
for(let i=0;i<1000;i++){
    read(i)
}

function read(obj){
 console.log(obj.toString());
}
for(let i=0;i<1000;i++){
    read(i%2===0?i:''+i)
}
function read(obj){
 console.log(obj.toString());
}
function read2(obj){
 console.log(obj.toString());
}
for(let i=0;i<1000;i++){
    read(i%2===0?i:''+i)
    i%2===0?read(i):read2(''+i)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 参考链接

  • http://www.zhufengpeixun.com/strong/html/144.3.gc.html
上次更新: 2022/04/15, 05:41:27
×