c++智能指针和java垃圾回收对比-cnblog

发布时间 2023-12-24 14:23:53作者: xyfyy

c++智能指针和java垃圾回收对比

我们都知道C++和java语言的一个巨大差异在于垃圾回收方面,这也是C++程序开发者和java程序开发者之间经常讨论的一个话题。

在C++语言中,一般栈上的内存随着函数的生命周期自动进行回收,但是堆上内存(也就是自己new/malloc出来的空间),需要自己手动进行delete/free,否则会造成内存泄漏。为了解决这个问题,C++中使用shared_ptr,对对象进行保护,shared_ptr的原理是引用计数,每对shared_ptr进行一次拷贝,会使ref_cnt++,当ref_cnt为0,会释放掉内存空间,从而避免了程序员主动控制内存释放,减少了内存泄漏的机会。使用引用计数方法,会导入一个新的问题:循环引用。


循环引用:
class A{
public:
    std::shared_ptr<B> b_ptr;
};
class B{
public:
    std::shared_ptr<A> a_ptr;
};

int test(){
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();
    a.b_ptr = b;
    b.a_ptr = a;
}
  1. 我们通过std::make_shared<A>()和std::make_shared<B>()分别创建了A和B对象的shared_ptr。在这个过程中,A对象和B对象的引用计数各自初始化为1。

  2. 我们将B对象的shared_ptr赋值给A对象的成员变量b_ptr。这将使B对象的引用计数增加1。此时,B对象的引用计数为2。

  3. 我们将A对象的shared_ptr赋值给B对象的成员变量a_ptr。这将使A对象的引用计数增加1。此时,A对象的引用计数为2。

  4. 当a和b变量超出作用域时,它们的析构函数会被调用。这将导致A对象和B对象的引用计数各自减1。然而,由于A对象的成员变量b_ptr仍然持有对B对象的引用,且B对象的成员变量a_ptr仍然持有对A对象的引用,所以它们的引用计数都为1。

所以当test函数执行结束,a对象和b对象不会被shared_ptr释放掉,但是我们也不能访问到对象的内存空间,也就导致了内存泄漏。

解决方法:使用weak_ptr

weak_ptr:
它指向一个由shared_ptr管理的对象而不影响所指对象的生命周期,也就是,将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的引用计数。其可以理解为shared_ptr的一个助手,可以通过lock将weak_ptr转化为shared_ptr,这样就会影响到引用计数,从而方便我们使用指针去操作响应对象。

image-20231224123808838

因此,我们只需要将上述A和B类中shared_ptr改成weak_ptr即可。

class A{
public:
    std::weak_ptr<B> b_ptr;
};
class B{
public:
    std::weak_ptr<A> a_ptr;
};

除了解决引用技术,weak_ptr也可以解决共享对象的线程安全问题。

#include <iostream>
#include <memory>
#include <thread>

class Test {
  public:
    Test(int id) : m_id(id) {}
    void showID() {
      std::cout << m_id << std::endl;
    }
  private:
    int m_id;
};

void thread1(Test* t) {
  std::this_thread::sleep_for(std::chrono::seconds(2));
  t->showID();                      // 打印结果:0
}

int main()
{
  Test* t = new Test(2);
  std::thread t1(thread1, t);
  delete t;
  t1.join();

  return 0;
}

t对象创建在堆上,可以被多线程共享。由于t1线程先sleep了2s,当执行showID时,一定已经被主线程delete掉了。从而导致内存非法访问,导致程序崩溃。

可以使用weak_ptr来避免这种问题

#include <iostream>
#include <memory>
#include <thread>

class Test {
  public:
    Test(int id) : m_id(id) {}
    void showID() {
      std::cout << m_id << std::endl;
    }
  private:
    int m_id;
};

void thread2(std::weak_ptr<Test> t) {
  std::this_thread::sleep_for(std::chrono::seconds(2));
  std::shared_ptr<Test> sp = t.lock();
  if(sp)
    sp->showID();                      // 打印结果:2
}

int main()
{
  std::shared_ptr<Test> sp = std::make_shared<Test>(2);
  std::thread t2(thread2, sp);
  t2.join();

  return 0;
}

此时,即便Test对象在主线程被释放,当使用weak_ptr时必须要lock,获取到shared_ptr,才能访问对象内存。lock过程中,是通过检测它所观察的强智能指针保存的Test对象的引用计数,来判定Test对象是否存活。此时Test对象被释放,lock失败,返回nullptr,再加空指针判断,即可避免内存非法访问的问题。

java中的垃圾回收机制,并不是采用引用计数的方式来实现的。参考《深入理解java虚拟机》中的代码:

public class Ref_cnt {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    private byte[] bigSize = new byte[2 * _1MB];
    public static void main(String[] args) {
        Ref_cnt objA = new Ref_cnt();
        Ref_cnt objB = new Ref_cnt();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        System.gc();
    }
}

运行发现没有响应的日志打印,觉得应该时配置参数的问题,经过一番查找,需要再Configuration中引入VM options: -XX: +PrintGCDetails

image-20231224133001930

打印部分结果如下:

image-20231224133347990

虽然不太看得懂...但是应该是回收了空间的意思?这也说明java不是使用引用计数来判断对象是否存活的。那么java的虚拟机是如何判断对象存活的呢?

<hr/

可达性分析算法

可达性分析算法,简单来说就是图的可达性判断,在系统中引入一些GC Roots(类比图的起点),通过引用链构成图的各条边,能够通过起点遍历到的顶点(对象),即表明可达,也就不会被回收。只有那些不能从GC Roots出发遍历到的才可以被回收。

GC Roots的选取方法:

在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法 堆栈中使用到的参数、局部变量、临时变量等。

在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。 ·在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。

在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。

Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象 (比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

所有被同步锁(synchronized 关键字)持有的对象。

反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

(这里还需要后续持续去理解)