金三银四:滥竽充数多拿2k指北(下)

213 阅读11分钟

声明:本文为原创文章,未经许可禁止转载

这篇文章是金三银四:滥竽充数多拿2k指北的下篇章节。

我的微信公众号:萌萌哒草头将军。 image.png

往期回顾:
我的代码简洁之道
优美的v-for列表加载动画:vue动画钩子实践
金三银四:滥竽充数多拿2k指北(上)

铂金

8. 垃圾回收(GC)*

不管什么语言,垃圾回收的整体策略都是一样的:先判断这块内存是否可回收,然后对可回收的内存使用回收器进行回收。

引用计数

怎么确定内存是否可回收的,业界的第一种做法是引用计数:如果一个对象被引用,就给这个对象的引用计数+1,如果不再使用这个对象,那就给这个对象的引用计数-1,每次触发GC流程时清除引用计数为零的对象。

标记-清除

引用计数基本是任何编程语言GC标配了,但是引用计数容易因为互相引用导致内存泄露,所以又出现了标记-清除:所有变量在进入内存前没有标记,如果对象被使用就将其标记,每次触发为GC流程时,清除没有被标记变量的内存。从上述可以发现,该策略分为两步:首先标记,然后清除

标记-清除也存在很大的缺陷:

  1. 每次运行GC时是扫描所有的变量,而有些变量是常驻变量。
  2. 清理掉的内存会成为内存碎片,导致内存成为不连续的片段。

所以很多编程语言在具体设计GC时,基于上述策略做了很多类似的优化。比如使用分代回收,避免每次扫描常驻对象,从而提升GC效率;将清理后的内存空间重写分配整理,避免形成内存碎片而浪费内存空间。 另外,所有的清除阶段,代码都是STW(Stop The World)状态。所以怎么缩短STW的时间也是各语言努力优化的目标。

v8引擎

不管是JavaScript还是Nodejs,都是运行在V8引擎(Chrome内核浏览器)上的,v8引擎为JavaScript的GC做了分代回收并行回收的优化,同时升级了标记-清除方案。

分代回收

首先将内存分为新生代区域和老生代区域,新生代里存放存活周期短的对象,老生代里存放存活周期长的常驻对象,这么做的好处就是,不用对一些常驻的对象频繁的做回收扫描,其次,又将新生代区域一分为二,一半作为使用区,一半作为空闲区。 新声明的变量会存入使用区,当使用区的剩余容量不足以存放新对象时,就会触发GC,大致的过程是将还在使用中(可达性分析确定的)对象复制到在空闲区然后清理,不使用的对象直接清理掉,然后将现在的空闲区标记为使用区,清理之后的使用区标记为空闲区。

并行回收 对于上述的新生代采用并行回收的方式。并行回收就是使用多个线程和主线程执行GC流程,并行回收的好处是可以缩短GC时间(不是成倍缩短,因为线程的通信也会消耗时间)。

标记清除和三色标记法 对于上述中的老生代采用标记清除的方法,它仍然是STW的,所以为了缩短STW时间,又将标记阶段切分为多个小段,每执行一小段就继续执行JavaScript代码,然后又执行小段的GC,反反复复......,v8将此称为增量标记 起初的标记清理,只是非黑即白的标记方式,如果在增量标记的场景下,当一小段增量标记完,下一小段增量标记开始时无法得知标记状态,所以V8采用了三色标记清除:未被标记时为白色、自身被标记但是成员未被标记时为灰色、自身和成员都被标记时是黑色。这样每段的增量标记都可以接着上段的标记继续工作了。

Java

Java使用引用计数和可达性分析作为GC策略,使用标记清除、标记整理、复制等GC方式

可达性分析 可达性分析是从GC Roots作为起始对象,依次寻找依赖的子对象,直至找不到依赖对象,如果此时对象没有和GC Root相连通,就会被判别为可回收对象(严格来说是准可回收,之后还有严格的验证措施)。

标记整理 将使用中的对象移到内存的另一端,将未使用的对象标记为可清除。

复制 将内存一分为二,一半作为使用区,一半作为空闲区,当使用区内存不足时,触发GC,将使用中的对象复制到空闲区,使用区的内存清空作为空闲区,前面的空闲区作为使用区,是不是很熟悉啊,没错V8新生代确实是借鉴了这里。

分代清理 Java的分代清理和JavaScript一样也是将内存分为新生代和老生代(永生代已经被移除了),不同的是,Java的新生代又被分为了三块,依次是:较大的Eden、较小的fron(s0)和to(s1)区,默认比例8 : 1 : 1 。对象优先在Eden区和from区,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC(新生代特有的GC算法),此时Eden区存活下来的变量将会被分配到to区,from区存活下来的变量年龄阈值,就会进入老年代,否则进入to区。此时清空Eden区和from区,然后将to区和from区调换身份。等待下一轮Minor GC

新生代使用的是Minor GC,老年代使用的是Full GC

Golang

Golong使用了引用计数标记清除三色标记清除。Golang的三色标记清除和v8的三色标记清除都是谷歌的产物,所以思路基本一致的,这里就不赘述了。值得一提的是,随着Golang的版本迭代,三色标记清除已经发展成并行的方式了。

Golang没有分代回收的概念,主要是因为它有内存逃逸机制。所谓内存逃逸就是当返回值时,内存也会被返回给系统,已经减轻了GC压力。自然,分代回收也就不需要了。

Python

在Python中,主要通过引用计数进行垃圾回收,通过标记-清除解决循环引用的问题,通过分代回收以空间换时间的方法提高垃圾回收效率。 Python GC将对象分成了三代:Generate ZeroGenerate OneGenerate Two,每一个新生对象在0代中,如果它在一轮GC后存活了下来,那么它将被移至1代,在那里它将较少的被扫描,如果它又活过了一轮GC,它又将被移到2代,在Generate Two被扫描的次数会更少。

9.跨平台方案

Node运行在服务端,为了可以跨平台(起初只能运行在Linux系统上),使用v8引擎作为JavaScript解释器,libuv作为跨平台依赖。 这俩兄弟的作用和Java的JVM十分相似,JVM负责将Java源代码解析编译成二进制文件、解释成操作系统可以识别的指令,然后由操作系统执行它。

但是在Golang面前,跨平台就是个弟弟,因为Golang采用交叉编译,在一个平台编译出来的代码是可以运行在其它平台。 Python更加独特,它有不同的编译器:CPython(默认编译器,c语言编写的)、Jython,CPython则把Python编译成Python字节码然后在不同平台上运行,因为不同的操作系统都有可以运行多种语言的GCC,而Jython则是把Python编译成Java字节码然后再利用JVM来运行在不同平台上。

10.异步编程
JavaScript

JavaScript当时仅仅用来和服务端交互,所以被设计成单线程语言(语言本身,浏览器是多进程多线程的),异步编程时只能采用回调函数或者Promise等方式,也没有并发。

Java

Java是多进程多线程语言,多线程就已经可以满足日常的并发需求了。不过多线程都会涉及线程状态和消息同步的问题。

Java的线程状态 一个线程被创建后成为初始(新建)状态,当调用start()之后进入就绪状态,表示可以被系统调用分配系统资源,当线程拿到系统分配的资源会调用run()方法,进入运行中状态,当线程失去系统分配的资源,比如执行了sleep(睡眠)suspend(挂起)就进入了阻塞状态。一个运行状态的线程完成任务或者其他终止条件发生时,该线程就切换到终止状态

消息同步

线程同步消息的方式是:基于Java内存模型(JMM)的内存共享和使用wait()notify()消息传递。内存共享时,多个线程对同一个全局变量进行写操作时,是可能造成冲突的(线程安全问题),解决的方案就是增加安全机制:当一个线程对一个变量进行写操作时,其余想要对这个变量进行写操作的线程必须等待该线程写操作结束,Java中实现这个功能,有两种方法:synchronized(Volatile是轻量级的同步,只能修饰变量)、ThreadLock

synchronized既可以修饰方法成为同步方法也可以包裹需要同步的代码块成为同步代码块。

//java
// 同步代码块
public void setCount () {
   synchronized (lock) {
       this.cout ++;
   }
}
// 同步方法
public synchronized void setCount () {
   synchronized (lock) {
       this.cout ++;
   }
}

如果同步代码里又包了别的同步代码,就会形成死锁.

以Java为例创建线程,需要实现Runable接口,或者继承Thread类(本质也是实现了Runable接口)。

// java
class ThreadTest extends Thread {
   // 保存当前线程
   private Thread t;
   // 线程名
   private String threadName;
   // 锁
   private Object lock;
   // 操作对象
   int count = 0;
   
   ThreadTest ( String name) {
      threadName = name;
      System.out.println("创建了线程:" +  threadName );
   }
   
   public void run() {
      System.out.println("线程" +  threadName + "运行中");
      try {
         while(true) {
            System.out.println("线程: " + threadName);
            // 做点啥吧
            setCount()
         }
      }catch (InterruptedException e) {
         System.out.println("线程 " +  threadName + " interrupted.");
      }
      System.out.println("线程 " +  threadName + " 结束了");
   }
   
   public void start () {
      System.out.println("开始线程:" +  threadName );
      if (t == null) {
         t = new Thread (this, threadName);
         t.start();
      }
   }
   // 同步代码块
   public void setCount () {
       synchronized (lock) {
           this.cout ++;
       }
   }
   // 同步方法
   public synchronized void setCount () {
       synchronized (lock) {
           this.cout ++;
       }
   }
}

但是使用多进程的最大的缺点是进程之间消息通信、Cpu上下文切换消耗很大,所以使用过多的线程并发编程,效率反而降低了。

Python

线程状态 Python的线程状态和Java十分相似,不同的是,Python中的运行状态是可以回到就绪状态的。

消息同步

Python消息同步时也有锁的概念,你可以将需要同步加锁的内容放在acquirerelease之间。

# python
# 这个例子来自菜鸟教程
import threading
import time

class myThread (threading.Thread):
    def __init__(self, threadID, name, delay):
        threading.Thread.__init__(self)
        self.threadID = threadID
        self.name = name
        self.delay = delay
    def run(self):
        print ("开启线程: " + self.name)
        # 获取锁,用于线程同步
        threadLock.acquire()
        print_time(self.name, self.delay, 3)
        # 释放锁,开启下一个线程
        threadLock.release()

def print_time(threadName, delay, counter):
    while counter:
        time.sleep(delay)
        print ("%s: %s" % (threadName, time.ctime(time.time())))
        counter -= 1

threadLock = threading.Lock()
threads = []

# 创建新线程
thread1 = myThread(1, "Thread-1", 1)
thread2 = myThread(2, "Thread-2", 2)

# 开启新线程
thread1.start()
thread2.start()

# 添加线程到线程列表
threads.append(thread1)
threads.append(thread2)

# 等待所有线程完成
for t in threads:
    t.join()
print ("退出主线程")
Golang

Golong使用轻量级线程异步编程,也就是大名鼎鼎的协程

协程状态 协程的状态虽然复杂,但是和上面对比,就比较容易理解了。

协程没有新建状态的,被创建后直接进入Grunnable(可运行状态),接着Golang会查询出所有可运行状态的协程将其状态切换为Grunning(运行状态),为了保证高并发性能,在系统调用前还要进行验证,这是会将状态切换为Gsyscall状态,如果通过验证,则又切换为Grunnable状态等待被系统调用,否则切换为Grunnable状态,如果通过验证后,还不到执行时机,则会切换为Gwaiting(阻塞状态)状态,时机成熟时又会被切换为Grunnable状态,当一个任务执行完成后,状态会切换为Gdead(狗带)状态。

协程通信

协程使用被叫做channel的通道通信。默认通道是没有缓存的,如果需要缓存,则需要使用第二个参数指定。

// go
// 无缓存
channel := make(chan int)
// 有缓存, 
channel := make(chan int, 100)

下面是Golang的协程代码示例,Golang使用关键字go就可以开启协程了。

package main

import "fmt"

func setCount (count int, c chan int) {
	fmt.Println("协程")
	count ++
	c <- count // 放入channel
}

func main () {

    var count int = 0
    
    c := make(chan int)
    
    go setCount(count, c)
    
    result := <- c // 从channel取出
    fmt.Println("count", result)
}

好了,这是我写过最长的文章了,筹备了三个周末,删删减减修改了很多次,希望看到这里的大家钟意Offer手到擒来。

别忘了关注我的微信公众号:萌萌哒草头将军。

image.png