作者 | 梁唐
大家好,我是梁唐。
相信大家应该都学过C语言或者是C++,C/C++当中令初学者比较头疼的可能就是指针了。毕竟用起来贼麻烦,要new来new去,用完了还得delete,一不小心就烫烫烫烫烫烫了。
我们今天不讲指针的这些技术细节,只聊一个问题,为什么设计者会设计出这么一个东西,难道不知道它很难用吗?
吐槽谁都会,但是吐槽完了还能去琢磨一下的,这就体现出差距了。
对于今天增删改查明天改查增删的程序员们来说,的确是没有使用指针的必要。我只要能从数据库里读取、写入数据就行,为什么非得用指针?
但是如果大家写过一些数据结构,尤其是一些相对比较复杂的数据结构立马就能感受到指针的香味。
我随便在网上找了一段SBT的代码片段,给大家演示一下:
void maintain(node *&o,bool d){
if(o->ch[d]->ch[d]->s>o->ch[!d]->s){
rotate(o,!d);
}else if(o->ch[d]->ch[!d]->s>o->ch[!d]->s){
rotate(o->ch[d],d);
rotate(o,!d);
}else return;
maintain(o->ch[1],1);
maintain(o->ch[0],0);
maintain(o,1);
maintain(o,0);
}
这段逻辑是用来维护二叉树的平衡的,也就是用来进行各种各样的旋转操作。代码逻辑看不懂没有关系,我们只要看下当中函数调用的部分,都是把一个孩子节点的指针丢进函数里去就结束了。
如果函数传递的不是指针的话,这段逻辑还成立吗?
显然就不成立了,因为函数传递参数是值传递,传入进去的值都会生成一个拷贝。我们在函数内部无论如何修改,也不会影响函数外的结果。
我之前用Python写过一次,因为Python当中没有指针。同样的数据结构就没这么方便,想要将一个节点替换成另外一个,需要先追溯到它的父节点,然后对它的父节点当中的内容进行修改。
再比如有了指针之后,我们可以实现动态分配内存。不仅如此,我们还可以直接操作内存地址,完成一些汇编语言才能实现的高端操作。
所以指针这个设计虽然会导致各种各样的问题,学习成本也不低,但肯定不是一无是处的。许多语言阉割掉了指针功能,虽然在一些问题和场景当中编码舒服了很多,但也遇到了很多其他的问题。
其中最大的问题就是内存管理的问题。
C/C++当中内存管理几乎都是由程序员来执行的,我们要使用一块内存的时候,就通过new/malloc来创建一个变量或者是数组,用完了之后就通过free或者是delete将它销毁。
这种做法的好处是程序员拥有最高的执行权限,我们可以自由控制内存的使用与销毁。像是Java、Python等语言,内存管理都是交给底层程序来控制的,我们在一块内存使用结束之后,无法确定它会在什么时候释放。
相比于交给程序去执行,由程序员执行内存管理本身并不是很糟糕的方案。毕竟程序是死的,总有一些特殊case处理不好。而人为处理,灵活性大大增加。
但遗憾的是,大部分情况下人比程序更加不靠谱,人工控制内存的问题明面上很好,但是隐患非常大,经常出现意外情况。
举几个例子,比如最常见的new了一块内存忘记了delete,或者是还没有delete就修改了指针,这样就会导致有一块内存申请好了放在那里,但是没有任何一个指针指向它,除非程序结束,再也无法释放。这也就是常说的内存泄漏。
除了程序员马虎忘记了delete之外,有时候一些意想不到的错误也会导致内存泄漏。另外一个很常见的情况如下:
Node node = new Node();
dosomething();
delete node;
很有可能我们在执行something的时候,报错了,然后异常抛出,导致delete的操作被跳过了。
除了内存泄漏之外,还有可能出现反向出问题的情况。比如一个指针,我们还没用完,下游某个地方还在使用,突然上游delete了,于是引发报错。更要命的时候,有些古老项目好几百万行,都不知道这个指针中间经历了什么,也没办法追溯它被delete的地方,有可能这整个链路上的逻辑异常复杂,导致你根本无力修改,只能特判这种情况,如果出现了就重新new一个,于是又增加了一个内存泄漏的隐患。
由程序员掌管内存的管理大权本身并没有什么问题,但问题是不是每一个工程师每时每刻都是诸葛亮,能够理解项目当中的每一个细节。尤其是当这个项目无比庞大了之后,动辄几百万行代码的项目,也根本超过了人类能够理解的极限。
最后的结果就是杂草丛生,问题无数,甚至工程师们无力解决已有的问题,只能往上添加更多的问题。
那把内存管理权限交给程序是否就高枕无忧了呢?
把内存完全交给程序管理,这就相当于从一个极端走向了另外一个极端。从完全人工控制走向了人工完全控制不了,其实也很有很多问题。
表面上减轻了程序员们的负担,甚至对于很多初学者来说完全没有意识到内存管理这个问题,就天然地以为这是编译器/解释器理所应当的天职。没有什么是理所应当的,当你以为理所应当的时候,往往就是问题产生的开始。
虽然各个语言的内存管理策略不尽相同,但往往大同小异,以其中比较典型的Java距离,做个介绍。
我们可以把Java中的内存看成几个桶,简化一下大概是四个桶。严格来说还有程序计数器、虚拟机栈、本地方法栈等内容,但是不太重要,就不一一列举了。
把这四个桶的原理理解了,基本上就能对Java内存管理做到一知半解了。先说方法区,顾名思义就是存储方法的地方。方法也就是我们开发程序的时候写的函数,只不过在Java当中统一称为方法,因为Java当中一切都是类,所有的函数都是某一个类的方法。
方法区的内容是存储在栈当中的,栈当中空间比较小一般存储一些程序执行时的上下文信息。比如当前方法调用栈信息,本地、虚拟机中的栈信息等等。
方法区当中的内存比较小,存储的东西也比较少,因此很少需要清理,只会在终极清理机制——full GC的时候清理。除了方法区之外的部分都是堆内存。
接着是新生代和老生代,新生代是两个空间大小相同的内存区域。当我们new一个新的实例的时候,开辟的内存其实就是新生代当中的内存。为什么新生代当中会有两个区域1和2呢?这是因为为了方便进行minor GC。
新生代当中必然有一个桶是空的,我们假设1是当前使用的,2是空闲的。当1内存满了之后,会触发minor GC。虚拟机会把1桶当中的内容一个一个按顺序倒出来,检查是否还在使用,如果还在使用就存入2当中,如果没在使用就丢弃。这样虽然空闲了一块区域,但是可以保证新生代当中的内存是连续的,保证了内存的利用率。
当一个实例经过好几次minor GC还没有被清理之后,就说明它活得可能会比较久,所以要移入老生代当中。老生代当中存储的都是这种经过了好几次GC还没被清理的老家伙。老家伙们活得很久, 而且往往占据的内存也比较大,所以针对这块内存设计了新的回收策略,即major GC。
由于老生代只有一块,所以我们没办法像是新生代一样按照顺序来回倒腾,只能一次性处理。这里采取的策略叫做CMS算法,其实就是标记回收算法。算法会根据这些老家伙的使用情况,给它们打上标签,看看哪些还在使用不能清理,哪些是已经没用的,可以干掉了。标签打完之后,会对这块内存整个清理,重新分配内存空间,保证清理之后的内存也是连续的。
当然由于这当中需要打标签,还需要移动内存分片,因此消耗的时间会比较久。内存分为新生代和老生代的策略也是尽量避免进行这样比较耗时的回收策略。
当进行full GC,也就是所有内存区域一同清理的时候会触发虚拟机stop the world,顾名思义也就是停止一切响应,埋头清理内存。这个时候会导致服务不可用,这也是Java的一大诟病之一,但这也是GC机制导致的。只能根据实际需要以及GC机制进行优化,降低频率,几乎不能根除。
也正是因为内存管理策略比较复杂,所以如果对内存这块没有深入的了解的话,很容易导致一些问题。比如频繁触发GC,导致系统经常无法响应。或者是干脆内存使用不合理,导致经常会出现内存溢出的情况,直接OOM崩溃。很多服务刚上线的时候运行得好好的,过了一段时间突然崩了,往往十有八九都是内存管理没过关。
所以很多被内存回收折腾得头疼的工程师又会怀念当年C++指针控制内存的方便,想用就用,想释放就释放,根本不用看虚拟机的脸色。但反过来C++那边也在觉得自动回收机制写代码方便,是历史潮流,所以新版的C++当中也开发了类似可智能回收指针这样的特性。
两边都在挣扎,其实类似的情况在代码设计当中非常非常常见,程序里永远没有完美,只有现实和妥协。
好了,关于指针就聊到这里,希望大家喜欢。