死磕java基础—谈谈那个写时拷贝技术(copy-on-write)(代码片段)

chenssy chenssy     2023-01-02     412

关键词:

大家好,我是大明哥。

个人网站:https://www.cmsblogs.com/


copy-on-write,即写时复制技术,这是小编在学习 Redis 持久化时看到的一个概念,当然在这个概念很早就碰到过(Java 容器并发有这个概念),但是一直都没有深入研究过,所以趁着这次机会对这个概念深究下。所以写篇文章记录下。

COW(copy-on-write 的简称),是一种计算机设计领域的优化策略,其核心思想是:如果有多个调用者(callers)同时要求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源(摘自 维基百科)。

Linux 中的 copy-on-write

要理解 Linux 的 COW,必须要清楚两个函数 fork()exec(),其中 exec() 是一组函数的统称,包括 execl()execlp()execv()execle()execve()execvp()

fork()

fork() 是什么?它是 UNIX 操作系统中派生新进程的唯一方法,用于创建子进程,该子进程等同于其父进程的副本,他们具有相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,注意是在执行 exec() 之前。

fork() 函数有一个特点就是,它是 调用一次,返回两次,调用是在父进程中调用创建子进程,返回有两个值,一个是返回给父进程,返回值为新子进程的进程 ID 号,一个返回给子进程,返回值为 0,所以我们基本上就可以根据返回值判断当前进程是子进程还是父进程。

因为任何子进程只有一个父进程,我们可以通过调用 getppid 获取父进程的进程 ID,而父进程可以拥有多个子进程,所以 fork() 之后返回的就是子进程的进程 ID,这样它才能识别它的子进程。

exec()

fork() 创建的子进程其实就是父进程的副本,如果仅仅只是 fork 一个父进程副本其实没有多大意义,我们肯定希望的子进程能够干一些活,一些与父进程不一样的活,这个时候函数 exec() 就派上用场了。它的作用是 装载一个新的程序,覆盖当前进程内存空间中的映像,从而执行不同的任务

比如父进程要打印 hello world ,fork 出来的子进程将也是打印 hello world的。但是当子进程执行 exec() 后,就不一定是打印 hello world 了,有可能是执行 1 + 1 = 2。如下图:

关于 fork()exec() 的文章推荐如下:

fork 会产生和父进程完全相同的子进程,如果采用传统的做法,会直接将父进程的数据复制到子进程中去,子进程创建完成后,父进程和子进程之间的数据段和堆栈就完成独立了,按照我们的惯例,子进程一般都会执行与父进程不一样的功能,exec() 后会将原有的数据清空,这样前面的复制过程就会变得无效了,这是一个非常浪费的过程,既然很多时间这种传统的复制方式是无效的,于是就有了 copy-on-write 技术的,原理也是非常简单的:

fork 的子进程与父进程共享内存空间,如果子进程不对内存空间进行修改的花,内存空间的数据并不会真实复制给子进程,这样的结果会让子进程创建的速度变得很快(不用复制,直接引用父进程的物理空间)。

fork 之后,子进程执行 exec() 也不会造成空间的浪费。

如下:

在网上看到还有个细节问题就是,fork之后内核会通过将子进程放在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行exec系统调用,因无意义的复制而造成效率的下降。

Copy On Write技术实现原理:

fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入kernel的一个中断例程。中断例程中,kernel就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。

Redis 中的 copy-on-write

我们知道 Redis 是单线程的,然后 Redis 的数据不可能一直存在内存中,肯定需要定时刷入硬盘中去的,这个过程则是 Redis 的持久化过程,那么作为单线程的 Redis 是怎么实现一边响应客户端命令一边持久化的呢?答案就是依赖 COW,具体来说就是依赖系统的 fork 函数的 COW 实现的。

Redis 持久化有两种:RDB 快照 和 AOF 日志。

RDB 快照表示的是某一时刻 Redis 内存中所有数据的写照。在执行 RDB 持久化时,Redis 进程会 fork 一个子进程来执行持久化过程,该过程是阻塞的,当 fork 过程完成后父进程会继续接收客户端的命令。子进程与 Redis 进程共享内存中的数据,但是子进程并不会修改内存中的数据,而是不断的遍历读取写入文件中,但是 Redis 父进程则不一样,它需要响应客户端的命令对内存中数据不断地修改,这个时候就会使用操作系统的 COW 机制来进行数据段页面的分离,当 Redis 父进程对其中某一个页面的数据进行修改时,则会将页面的数据复制一份出来,然后对这个复制页进行修改,这个时候子进程相应的数据页并没有发生改变,依然是 fork 那一瞬间的数据。

AOF 日志则是将每个收到的写命令都写入到日志文件中来保证数据的不丢失。但是这样会产生一个问题,就是随着时间的推移,日志文件会越来越大,所以 Redis 提供了一个重写过程(bgrewriteaof)来对日志文件进行压缩。该重写过程也会调用 fork() 函数产生一个子进程来进行文件压缩。

关于 Redis 的持久化,请看这篇文章:【死磕 Redis】---- Redis 的持久化

Java 中的 copy-on-write

熟悉 Java 并发的同学一定知道 Java 中也有两个容器使用了 copy-on-write 机制,他们分别是 CopyOnWriteArrayList 和 CopyOnWriteArraySet,他在我们并发使用场景中用处还是挺多的。现在我们就 CopyOnWriteArrayList 来简单分析下 Java 中的 copy-on-write。

CopyOnWriteArrayList 实现 List 接口,底层的实现是采用数组来实现的。内部持有一个私有数组 array 用于存放各个元素。

private transient volatile Object[] array;

该数组不允许直接访问,只允许 getArray()setArray() 访问。

    final Object[] getArray() 
        return array;
    

    final void setArray(Object[] a) 
        array = a;
    

既然是 copy-on-write 机制,那么对于读肯定是直接访问该成员变量 array,如果是其他修改操作,则肯定是先复制一份新的数组出来,然后操作该新的数组,最后将指针指向新的数组即可,以 add 操作为例,如下:

    public boolean add(E e) 
        final ReentrantLock lock = this.lock;
        lock.lock();
        try 
            // 获取老数组
            Object[] elements = getArray();
            int len = elements.length;
            
            // 复制出新数组
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            
            // 添加元素到新数组中
            newElements[len] = e;
            
            //把原数组引用指向新数组
            setArray(newElements);
            return true;
         finally 
            lock.unlock();
        
    

添加的时候使用了锁,如果不使用锁的话,可能会出现多线程写的时候出现多个副本。

读操作如下:

    public E get(int index) 
        return get(getArray(), index);
    
    
    private E get(Object[] a, int index) 
        return (E) a[index];
    

读操作没有加锁,则可能会出现脏数据。

所以 Java 中的 COW 容器的原理如下:

当我们在修改一个容器中的元素时,并不是直接操作该容器,而是将当前容器进行 copy,复制出一个新的容器,然后在再对该新容器进行操作,操作完成后,将原容器的引用指向新容易,读操作直接读取老容器即可。

它体现的也是一种懒惰原则,也有点儿读写分离的意思(读和写操作的是不用的容器)

这两个容器适合读多写少的场景,毕竟每次写的时候都要获取锁和对数组进行复制处理,性能是大问题。

关于 Java 的 COW 更多资料,请看这篇文章:聊聊并发-Java中的Copy-On-Write容器

参考资料

写时拷贝cow(copy-on-write)

   写时拷贝技术是通过"引用计数"实现的,在分配空间的时候多分配4个字节,用来记录有多少个指针指向块空间,当有新的指针指向这块空间时,引用计数加一,当要释放这块空间时,引用计数减一(假装释放),直到... 查看详情

英文copy是啥意思?

...zero-copy零复制;零拷贝;零复印;拷贝copy-on-write写入时复制;写时复制;写时拷贝;技术参考技术A英文copy是什么意思?英文copy翻译成中文意思是:复制;复印;模仿;仿造;临摹;抄写;誊写;效法;仿效。 参考技术B英文copy的中文... 查看详情

写时拷贝技术

CopyOnWrite(COW):写时拷贝技术一、什么是写时拷贝技术:写时拷贝技术可以理解为“写的时候才去分配空间”,这实际上是一种拖延战术。举个栗子:650)this.width=650;"src="http://img.blog.csdn.net/20160829170427832?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkb... 查看详情

探秘写时拷贝的真相发布啦!

导读写时拷贝(copy-on-write,COW)就是等到修改数据时才真正分配内存空间,这是对程序性能的优化,可以延迟甚至是避免内存拷贝,当然目的就是避免不必要的内存拷贝。什么是写时拷贝其实我们对写时... 查看详情

jvm技术专题「原理专题」让我们一起探索一下netty(java)底层的“零拷贝zero-copy”技术(上)(代码片段)

Netty的零拷贝Netty中的零拷贝与我们传统理解的零拷贝不太一样。传统的零拷贝指的是数据传输过程中,不需要CPU进行数据的拷贝。主要是数据在用户空间与内核中间之间的拷贝。传统意义的零拷贝Zero-Copydescribescomputeroperations... 查看详情

java两种zero-copy零拷贝技术mmap和sendfile的介绍(代码片段)

详细介绍了两种zero-copy零拷贝技术mmap和sendfile的概念和基本原理。文章目录1标准IO2零拷贝2.1sendfile调用2.1mmap调用2.2MQ中的应用1标准IO很多软件是基于server-client模式的,最常见的下载功能需要从Server端的磁盘中将文件通过网络... 查看详情

难道是“写时拷贝”?

前言:1#if023其实,现在我要做的这件事情,是有个前提的,4有一天晚上,我和一个朋友讨论一个相关技术的问题,5(因为我也不是很懂,我不确定我的观点是正确的,所以才是讨论),6我们聊到了,Windows的映射机制,7我们模... 查看详情

java中写时复制(copy-on-write)map实现(代码片段)

什么是写时复制(Copy-On-Write)容器?写时复制是指:在并发访问的情景下,当需要修改JAVA中Containers的元素时,不直接修改该容器,而是先复制一份副本,在副本上进行修改。修改完成之后,将指向原来容器的引用指向新的容器(副... 查看详情

string类的深浅拷贝,写时拷贝

string类的深浅拷贝,写时拷贝浅拷贝:多个指针指向同一块空间,多次析构同一块内存空间,系统会崩溃。(浅拷贝就是值拷贝)深拷贝:给指针开辟新的空间,把内容拷贝进去,每个指针都指向自己的内存空间,析构时不会内... 查看详情

c++深浅拷贝写时拷贝(代码片段)

...f1a;本章以string类为例介绍浅拷贝与深拷贝,引用计数写时拷贝作为了解内容,string类的模拟实现参考C++string类的模拟实现。文章目录1.浅拷贝2.深拷贝3.引用计数+写时拷贝1.浅拷贝浅拷贝:对于有申请空间的... 查看详情

c++——浅拷贝深拷贝写时拷贝详解(代码片段)

C++——浅拷贝、深拷贝、写时拷贝详解浅拷贝与深拷贝解决浅拷贝的问题——引用计数写时拷贝浅拷贝与深拷贝用String类模拟用将“/0”拷贝进去:调用系统默认的拷贝构造函数,结果就是内容相同,地址相同... 查看详情

java浅拷贝和深拷贝(基础也是很重要的)

...许只是懵懂,或者是并没在意,来了解下吧。对于的github基础代码https://github.com/chywx/JavaSE最近学习c++,跟java很是相像,在慕课网学习c++也算是重温习了下java基础明白了当初讲师一直强调java传递的话只有值传递,不存在引用传... 查看详情

string类的写时拷贝

...当我们需要写的时候才去新开辟内存空间。这种方法就是写时拷贝。 在构造函数中开辟新的空间时多 查看详情

深拷贝&浅拷贝&引用计数&写时拷贝

(1).浅拷贝:classString{public:String(constchar*str=""):_str(newchar[strlen(str)+1]){strcpy(_str,str);}~String(){if(NULL!=_str){delete[]_str;_str=NULL;}}private:char*_str;};intmain(){Strings1("hello");Str 查看详情

docker存储技术浅析(代码片段)

...镜像层共享以及写时复制(CoW)技术的具体实现。Docker存储基础技术镜像分层所有的Docker镜像都起始于一个基础镜像层,当进行修改或增加新的内容时,就会在当前镜像层之上,创建新的镜像层。默认Docker镜像由多个只读层镜像叠... 查看详情

写时拷贝(copyonwrite)(代码片段)

    适用于深拷贝提高效率的一种方法。    例如string类,在拷贝构造时,会要用到深拷贝。如果用浅拷贝,会导致两对象的指针指向同一块空间,导致对象析构时,导致同一块空间释放两次,程序奔... 查看详情

java深浅copy(代码片段)

1.深浅copy的定义  1.浅拷贝:只复制一个对象,对象内部存在的指向其他对象数组或者引用则不复制。  2.深拷贝:对象,对象内部的引用均复制。1.1浅拷贝图示  为了更好的理解它们的区别我们假设有一个对象A,它包含... 查看详情

string类的实现写时拷贝浅析

...当我们需要写的时候才去新开辟内存空间。这种方法就是写时拷贝。这也是一种解决由于浅拷贝使多个对象共用一块内存地址,调用析构函数时导致一块内存被多次释放,导致程序奔溃的问题。这种方法同样需要用到引用计数:... 查看详情