60亿次for循环,原来这么多东西

时间:2022-07-24
本文章向大家介绍60亿次for循环,原来这么多东西,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

起因

  • 有人在思否论坛上向我付费提问
  • 当时觉得,这个人问的有问题吧。仔细一看,还是有点东西的

问题重现

  • 编写一段Node.js代码
var http = require('http');
  
http.createServer(function (request, response) {
    var num = 0
    for (var i = 1; i < 5900000000; i++) {
        num += i
    }
    response.end('Hello' + num);
}).listen(8888);
  • 使用nodemon启动服务,用time curl调用这个接口
  • 首次需要7.xxs耗时
  • 多次调用后,问题重现
  • 为什么这个耗时突然变高,由于我是调用的是本机服务,我看CPU使用当时很高,差不多打到100%了.但是我后面发现不是这个问题.

问题排查

  • 排除掉CPU问题,看内存消耗占用。
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    console.time('测试');
    let num = 0;
    for (let i = 1; i < 5900000000; i++) {
      num += i;
    }
    console.timeEnd('测试');
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end('Hello' + num);
![](https://imgkr2.cn-bj.ufileos.com/13455121-9d87-42c3-a32e-ea999a2cd09b.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=E3cF2kymC92LifrIC5IOfIZQvnk%253D&Expires=1598883364)

![](https://imgkr2.cn-bj.ufileos.com/1e7b95df-2a48-41c3-827c-3c24b39f4b5b.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=%252FANTTuhgbpIsXslXMc1qCkj2TMU%253D&Expires=1598883362)

  })
  .listen(8888);
  • 测试结果:
  • 内存占用和CPU都正常
  • 跟字符串拼接有关,此刻关闭字符串拼接(此时为了快速测试,我把循环次数降到5.9亿次
  • 发现耗时稳定下来了

定位问题在字符串拼接,先看看字符串拼接的几种方式

  • 一、使用连接符 “+” 把要连接的字符串连起来
var a = 'java'
var b = a + 'script'

  * 只连接100个以下的字符串建议用这种方法最方便

  • 二、使用数组的 join 方法连接字符串
var arr = ['hello','java','script']
var str = arr.join("")
  • 比第一种消耗更少的资源,速度也更快
  • 三、使用模板字符串,以反引号( ` )标识
var a = 'java'
var b = `hello ${a}script`
  • 四、使用 JavaScript concat() 方法连接字符串
var a = 'java'
var b = 'script'

var str = a.concat(b)

五、使用对象属性来连接字符串

function StringConnect(){
    this.arr = new Array()
}

StringConnect.prototype.append = function(str) {
    this.arr.push(str)
}

StringConnect.prototype.toString = function() {
    return this.arr.join("")
}

var mystr = new StringConnect()

mystr.append("abc")
mystr.append("def")
mystr.append("g")

var str = mystr.toString()

更换字符串的拼接方式

  • 我把字符串拼接换成了数组的join方式(此时循环5.9亿次)
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    console.time('测试');
    let num = 0;
    for (let i = 1; i < 590000000; i++) {
      num += i;
    }
    const arr = ['Hello'];
    arr.push(num);
    console.timeEnd('测试');
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end(arr.join(''));
  })
  .listen(8888);
  • 测试结果,发现接口调用的耗时稳定了(注意此时是5.9亿次循环)
  • 《javascript高级程序设计》中,有一段关于字符串特点的描述,原文大概如下:ECMAScript中的字符串是不可变的,也就是说,字符串一旦创建,他们的值就不能改变。要改变某个变量的保存的的字符串,首先要销毁原来的字符串,然后再用另外一个包含新值的字符串填充该变量

就完了?

  • +直接拼接字符串自然会对性能产生一些影响,因为字符串是不可变的,在操作的时候会产生临时字符串副本,+操作符需要消耗时间,重新赋值分配内存需要消耗时间。
  • 但是,我更换了代码后,发现,即使没有字符串拼接,也会耗时不稳定
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    console.time('测试');
    let num = 0;
    for (let i = 1; i < 5900000000; i++) {
    //   num++;
    }
    const arr = ['Hello'];
    // arr[1] = num;
    console.timeEnd('测试');
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end('hello');
  })
  .listen(8888);
  • 测试结果:
  • 现在我怀疑,不仅仅是字符串拼接的效率问题,更重要的是for循环的耗时不一致
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    let num = 0;
    console.time('测试');
    for (let i = 1; i < 5900000000; i++) {
    //   num++;
    }
    console.timeEnd('测试');
    const arr = ['Hello'];
    // arr[1] = num;
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end('hello');
  })
  .listen(8888);
  • 测试运行结果:
  • for循环内部的i++其实就是变量不断的重新赋值覆盖
  • 经过我的测试发现,40亿次50亿次的区别,差距很大,40亿次的for循环,都是稳定的,但是50亿次就不稳定了.
  • Node.jsEventLoop:
  • 我们目前被阻塞的状态:
  • 我电脑的CPU使用情况

优化方案

  • 遇到了60亿次的循环,像有使用多进程异步计算的,但是本质上没有解决这部分循环代码的调用耗时。
  • 改变策略,拆解单次次数过大的for循环:
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    let num = 0;
    console.time('测试');
    for (let i = 1; i < 600000; i++) {
      num++;
      for (let j = 0; j < 10000; j++) {
        num++;
      }
    }
    console.timeEnd('测试');
    const arr = ['Hello'];
    console.log(num, 'num');
    arr[1] = num;
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end(arr.join(''));
  })
  .listen(8888);
  • 结果,耗时基本稳定,60亿次循环总共:

推翻字符串的拼接耗时说法

  • 修改代码回最原始的+方式拼接字符串
var http = require('http');

http
  .createServer(function(request, response) {
    console.log(request.url, 'url');
    let used = process.memoryUsage().heapUsed / 1024 / 1024;

    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'start',
    );
    let num = 0;
    console.time('测试');
    for (let i = 1; i < 600000; i++) {
      num++;
      for (let j = 0; j < 10000; j++) {
        num++;
      }
    }
    console.timeEnd('测试');
    // const arr = ['Hello'];
    console.log(num, 'num');
    // arr[1] = num;
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `The script uses approximately ${Math.round(used * 100) / 100} MB`,
      'end',
    );
    response.end(`Hello` + num);
  })
  .listen(8888);
  • 测试结果稳定,符合预期:

总结:

  • 对于单次循环超过一定阀值次数的,用拆解方式,Node.js的运行耗时是稳定,但是如果是循环次数过多,那么就会出现刚才那种情况,阻塞严重,耗时不一样。
  • 为什么?

深度分析问题

  • 遍历60亿次,这个数字是有一些大了,如果是40亿次,是稳定的
  • 这里应该还是跟CPU有一些关系,因为top查看一直是在升高
  • 此处虽然不是真正意义上的内存泄漏,但是我们如果在一个循环中不仅要不断更新i的值到60亿,还要不断更新num的值60亿,内存使用会不断上升,最终出现两份60亿的数据,然后再回收。(因为GC自动垃圾回收,一样会阻塞主线程,多次接口调用后,CPU占用也会升高)
  • 使用for循环拆解后:
 for (let i = 1; i < 60000; i++) {
      num++;
      for (let j = 0; j < 100000; j++) {
        num++;
      }
    }
  • 只要num60亿即可,解决了这个问题。

哪些场景会遇到这个类似的超大计算量问题:

  • 图片处理
  • 加解密

如果是异步的业务场景,也可以用多进程参与解决超大计算量问题,今天这里就不重复介绍了

最后