O que acontece com o lixo em C ++?

51

O Java tem um GC automático que, de vez em quando, pára o mundo, mas cuida do lixo em uma pilha. Agora, os aplicativos C / C ++ não possuem esses congelamentos de STW, e seu uso de memória também não aumenta infinitamente. Como esse comportamento é alcançado? Como os objetos mortos são atendidos?

    
por Ju Shua 16.06.2016 / 16:26
fonte

8 respostas

101

O programador é responsável por garantir que os objetos criados por meio de new sejam excluídos via delete . Se um objeto é criado, mas não destruído antes que o último ponteiro ou referência a ele saia do escopo, ele cai nas rachaduras e se torna um Vazamento de memória .

Infelizmente para C, C ++ e outras linguagens que não incluem um GC, isso simplesmente se acumula com o tempo. Isso pode fazer com que um aplicativo ou o sistema fique sem memória e não consiga alocar novos blocos de memória. Neste ponto, o usuário deve recorrer ao encerramento do aplicativo para que o sistema operacional possa recuperar a memória usada.

No que diz respeito a atenuar este problema, existem várias coisas que tornam a vida de um programador muito mais fácil. Eles são suportados principalmente pela natureza do escopo .

int main()
{
    int* variableThatIsAPointer = new int;
    int variableInt = 0;

    delete variableThatIsAPointer;
}

Aqui, criamos duas variáveis. Eles existem no Block Scope , conforme definido pelas chaves {} . Quando a execução se move para fora desse escopo, esses objetos serão excluídos automaticamente. Nesse caso, variableThatIsAPointer , como o próprio nome indica, é um ponteiro para um objeto na memória. Quando ele sai do escopo, o ponteiro é excluído, mas o objeto para o qual ele aponta permanece. Aqui, nós delete este objeto antes de sair do escopo para garantir que não haja vazamento de memória. No entanto, poderíamos ter passado este ponteiro para outro lugar e esperado que ele fosse apagado mais tarde.

Essa natureza do escopo se estende a classes:

class Foo
{
public:
    int bar; // Will be deleted when Foo is deleted
    int* otherBar; // Still need to call delete
}

Aqui, o mesmo princípio se aplica. Não precisamos nos preocupar com bar quando Foo for excluído. No entanto, para otherBar , apenas o ponteiro é excluído. Se otherBar for o único ponteiro válido para qualquer objeto para o qual ele aponta, devemos provavelmente delete em% debug% s. Este é o conceito de direção por trás do RAII

resource allocation (acquisition) is done during object creation (specifically initialization), by the constructor, while resource deallocation (release) is done during object destruction (specifically finalization), by the destructor. Thus the resource is guaranteed to be held between when initialization finishes and finalization starts (holding the resources is a class invariant), and to be held only when the object is alive. Thus if there are no object leaks, there are no resource leaks.

O RAII também é a força motriz típica por trás dos Ponteiros inteligentes . Na C ++ Standard Library, estes são Foo , std::shared_ptr e std::unique_ptr ; embora eu tenha visto e usado outras implementações std::weak_ptr / shared_ptr que seguem os mesmos conceitos. Para estes, um contador de referência controla quantos ponteiros existem para um determinado objeto e automaticamente weak_ptr s o objeto, uma vez que não há mais referências a ele.

Além disso, tudo se resume a práticas e disciplina adequadas para um programador garantir que seu código manipule objetos adequadamente.

    
por 28.06.2016 / 23:04
fonte
84

C ++ não tem coleta de lixo.

Os aplicativos C ++ são necessários para descartar seu próprio lixo.

Os programadores de aplicações C ++ precisam entender isso.

Quando eles esquecem, o resultado é chamado de "vazamento de memória".

    
por 16.06.2016 / 16:29
fonte
43

Em C, C ++ e outros sistemas sem um Garbage Collector, o desenvolvedor recebe facilidades pela linguagem e suas bibliotecas para indicar quando a memória pode ser recuperada.

A instalação mais básica é o armazenamento automático . Muitas vezes, a própria linguagem garante que os itens sejam descartados:

int global = 0; // automatic storage

int foo(int a, int b) {
    static int local = 1; // automatic storage

    int c = a + b; // automatic storage

    return c;
}

Nesse caso, o compilador é responsável por saber quando esses valores não são usados e recuperar o armazenamento associado a eles.

Ao usar armazenamento dinâmico , em C, a memória é tradicionalmente alocada com malloc e recuperada com free . Em C ++, a memória é tradicionalmente alocada com new e recuperada com delete .

C não mudou muito ao longo dos anos, mas o C ++ moderno evita completamente new e delete e confia nas instalações da biblioteca (que usam new e delete apropriadamente):

  • os ponteiros inteligentes são os mais famosos: std::unique_ptr e std::shared_ptr
  • mas os contêineres são muito mais difundidos na verdade: std::string , std::vector , std::map , ... todos gerenciam internamente a memória alocada dinamicamente de forma transparente

Falando de shared_ptr , existe um risco: se um ciclo de referências é formado e não quebrado, então pode haver vazamento de memória. Cabe ao desenvolvedor evitar essa situação, a maneira mais simples é evitar shared_ptr e o segundo é evitar ciclos no nível do tipo.

Como resultado, os vazamentos de memória não são um problema em C ++ , mesmo para novos usuários, contanto que eles evitem usar new , delete ou std::shared_ptr . Isto é diferente de C, onde uma disciplina convicta é necessária e geralmente insuficiente.

No entanto, essa resposta não estaria completa sem mencionar a irmã gêmea de vazamentos de memória: ponteiros pendentes .

Um ponteiro pendente (ou referência pendente) é um risco criado mantendo um ponteiro ou referência a um objeto que está morto. Por exemplo:

int main() {
    std::vector<int> vec;
    vec.push_back(1);     // vec: [1]

    int& a = vec.back();

    vec.pop_back();       // vec: [], "a" is now dangling

    std::cout << a << "\n";
}

Usar um ponteiro pendente ou referência é Comportamento Indefinido . Em geral, felizmente, isso é um acidente imediato; muitas vezes, infelizmente, isso causa corrupção de memória primeiro ... e, de tempos em tempos, comportamentos estranhos surgem porque o compilador emite códigos realmente estranhos.

Comportamento indefinido é o maior problema com C e C ++ até hoje, em termos de segurança / correção de programas. Você pode querer verificar o Rust para um idioma sem Coletor de Lixo e sem Comportamento Indefinido.

    
por 16.06.2016 / 18:04
fonte
27

C ++ tem essa coisa chamada RAII . Basicamente, significa que o lixo é limpo à medida que você vai em vez de deixá-lo em uma pilha e deixar o aspirador arrumar depois de você. (imagine-me no meu quarto assistindo ao futebol - como eu bebo latas de cerveja e preciso de novas, o jeito C ++ é levar a lata vazia para o lixo no caminho para a geladeira, o jeito C # é jogá-la no chão e espere a empregada pegá-los quando ela fizer a limpeza).

Agora é possível vazar memória em C ++, mas fazer isso requer que você deixe as construções usuais e reverta para o modo C de fazer as coisas - alocar um bloco de memória e manter o controle de onde está esse bloco sem nenhuma assistência de idioma . Algumas pessoas esquecem esse ponteiro e, portanto, não podem remover o bloco.

    
por 16.06.2016 / 17:07
fonte
26

Deve-se notar que é, no caso do C ++, um equívoco comum que "você precisa fazer gerenciamento de memória manual". Na verdade, você normalmente não faz nenhum gerenciamento de memória em seu código.

Objetos de tamanho fixo (com vida útil do escopo)

Na grande maioria dos casos, quando você precisa de um objeto, o objeto terá uma duração definida em seu programa e será criado na pilha. Isso funciona para todos os tipos de dados primitivos internos, mas também para instâncias de classes e estruturas:

class MyObject {
    public: int x;
};

int objTest()
{
    MyObject obj;
    obj.x = 5;
    return obj.x;
}

Os objetos de pilha são removidos automaticamente quando a função termina. Em Java, os objetos são sempre criados no heap e, portanto, precisam ser removidos por algum mecanismo, como a coleta de lixo. Este não é um problema para objetos de pilha.

Objetos que gerenciam dados dinâmicos (com vida útil do escopo)

O uso de espaço na pilha funciona para objetos de tamanho fixo. Quando você precisa de uma quantidade variável de espaço, como uma matriz, outra abordagem é usada: A lista é encapsulada em um objeto de tamanho fixo que gerencia a memória dinâmica para você. Isso funciona porque os objetos podem ter uma função especial de limpeza, o destruidor. É garantido que seja chamado quando o objeto sai do escopo e faz o oposto do construtor:

class MyList {        
public:
    // a fixed-size pointer to the actual memory.
    int* listOfInts; 
    // constructor: get memory
    MyList(size_t numElements) { listOfInts = new int[numElements]; }
    // destructor: free memory
    ~MyList() { delete[] listOfInts; }
};

int listTest()
{
    MyList list(1024);
    list.listOfInts[200] = 5;
    return list.listOfInts[200];
    // When MyList goes off stack here, its destructor is called and frees the memory.
}

Não há gerenciamento de memória no código onde a memória é usada. A única coisa que precisamos ter certeza é que o objeto que escrevemos tem um destruidor adequado. Não importa como deixamos o escopo de listTest , seja por meio de uma exceção ou simplesmente retornando dele, o destrutor ~MyList() será chamado e não precisamos gerenciar nenhuma memória.

(eu acho uma decisão engraçada de design usar o operador binary NOT , ~ , para indicar o destruidor. Quando usado em números, ele inverte os bits; em analogia , aqui indica que o que o construtor fez é invertido.)

Basicamente, todos os objetos C ++ que precisam de memória dinâmica usam esse encapsulamento. Ela tem sido chamada de RAII ("aquisição de recursos é inicialização"), o que é uma maneira bastante estranha de expressar a idéia simples de que os objetos se importam com seus próprios conteúdos; o que eles adquirem é deles para limpar.

Objetos polimórficos e tempo de vida além do escopo

Agora, ambos os casos foram para memória que tem um tempo de vida claramente definido: O tempo de vida é o mesmo que o escopo. Se não queremos que um objeto expire quando deixamos o escopo, existe um terceiro mecanismo que pode gerenciar a memória para nós: um ponteiro inteligente. Os ponteiros inteligentes também são usados quando você tem instâncias de objetos cujo tipo varia em tempo de execução, mas que têm uma interface ou classe base comum:

class MyDerivedObject : public MyObject {
    public: int y;
};
std::unique_ptr<MyObject> createObject()
{
    // actually creates an object of a derived class,
    // but the user doesn't need to know this.
    return std::make_unique<MyDerivedObject>();
}

int dynamicObjTest()
{
    std::unique_ptr<MyObject> obj = createObject();
    obj->x = 5;
    return obj->x;
    // At scope end, the unique_ptr automatically removes the object it contains,
    // calling its destructor if it has one.
}

Existe outro tipo de ponteiro inteligente, std::shared_ptr , para compartilhar objetos entre vários clientes. Eles apenas excluem seu objeto contido quando o último cliente sai do escopo, para que possam ser usados em situações em que é completamente desconhecido quantos clientes haverá e por quanto tempo eles usarão o objeto.

Em resumo, vemos que você não faz nenhum gerenciamento manual de memória. Tudo é encapsulado e é cuidado por meio do gerenciamento de memória totalmente automático e baseado em escopo. Nos casos em que isso não é suficiente, são usados ponteiros inteligentes que encapsulam a memória bruta.

Considera-se uma prática extremamente ruim usar ponteiros brutos como proprietários de recursos em qualquer lugar no código C ++, alocações brutas fora de construtores e% rawdelete chamadas fora de destruidores, pois são quase impossíveis de gerenciar quando ocorrem exceções e geralmente difícil de usar com segurança.

O melhor: isso funciona para todos os tipos de recursos

Um dos maiores benefícios do RAII é que ele não está limitado à memória. Na verdade, ele fornece uma maneira muito natural de gerenciar recursos, como arquivos e sockets (abertura / fechamento) e mecanismos de sincronização, como mutexes (bloqueio / desbloqueio). Basicamente, todos os recursos que podem ser adquiridos e devem ser liberados são gerenciados exatamente da mesma maneira em C ++, e nenhum desses gerenciamento é deixado para o usuário. É todo encapsulado em classes que adquirem no construtor e liberam no destruidor.

Por exemplo, uma função bloqueando um mutex é normalmente escrita assim em C ++:

void criticalSection() {
    std::scoped_lock lock(myMutex); // scoped_lock locks the mutex
    doSynchronizedStuff();
} // myMutex is released here automatically

Outros idiomas tornam isso muito mais complicado, seja exigindo que você faça isso manualmente (por exemplo, em uma cláusula finally ) ou gerando mecanismos especializados que resolvem esse problema, mas não de uma maneira particularmente elegante (geralmente mais tarde em vida, quando pessoas suficientes sofreram com a deficiência). Tais mecanismos são try-with-resources em Java e a instrução using em C #, ambos os quais são aproximações do RAII do C ++.

Então, para resumir, tudo isso foi uma conta muito superficial do RAII em C ++, mas espero que ajude os leitores a entender que a memória e até mesmo o gerenciamento de recursos em C ++ não são geralmente "manuais", mas na verdade, na maioria das vezes automático.

    
por 17.06.2016 / 02:17
fonte
9

Em relação especificamente ao C, a linguagem não oferece ferramentas para gerenciar a memória alocada dinamicamente. Você é absolutamente responsável por garantir que cada *alloc tenha um free correspondente em algum lugar.

Onde as coisas ficam realmente desagradáveis é quando uma alocação de recursos falha no meio; você tenta de novo, você reverte e começa de novo desde o começo, você rola para trás e sai com um erro, você simplesmente socorre completamente e deixa o sistema operacional lidar com isso?

Por exemplo, aqui está uma função para alocar um array 2D não contíguo. O comportamento aqui é que, se uma falha de alocação ocorrer no meio do processo, nós rolamos tudo de volta e retornamos uma indicação de erro usando um ponteiro NULL:

/**
 * Allocate space for an array of arrays; returns NULL
 * on error.
 */
int **newArr( size_t rows, size_t cols )
{
  int **arr = malloc( sizeof *arr * rows );
  size_t i;

  if ( arr ) // malloc returns NULL on failure
  {
    for ( i = 0; i < rows; i++ )
    {
      arr[i] = malloc( sizeof *arr[i] * cols );
      if ( !arr[i] )
      {
        /**
         * Whoopsie; we can't allocate any more memory for some reason.
         * We can't just return NULL at this point since we'll lose access
         * to the previously allocated memory, so we branch to some cleanup
         * code to undo the allocations made so far.  
         */
        goto cleanup;
      }
    }
  }
  goto done;

/**
 * We encountered a failure midway through memory allocation,
 * so we roll back all previous allocations and return NULL.
 */
cleanup:
  while ( i )         // this is why we didn't limit the scope of i to the for loop
    free( arr[--i] ); // delete previously allocated rows
  free( arr );        // delete arr object
  arr = NULL;

done:
  return arr;
}

Este código é butt-feio com aqueles goto s, mas, na ausência de qualquer tipo de mecanismo de manipulação de exceção estruturada, esta é praticamente a única maneira de lidar com o problema sem resgatando completamente, especialmente se o seu código de alocação de recursos estiver aninhado em mais de um loop profundo. Esta é uma das poucas vezes em que goto é realmente uma opção atraente; caso contrário, você está usando vários sinalizadores e instruções if extras.

Você pode facilitar a vida de si mesmo escrevendo funções dedicadas de alocador / desalocador para cada recurso, algo como

Foo *newFoo( void )
{
  Foo *foo = malloc( sizeof *foo );
  if ( foo )
  {
    foo->bar = newBar();
    if ( !foo->bar ) goto cleanupBar;
    foo->bletch = newBletch(); 
    if ( !foo->bletch ) goto cleanupBletch;
    ...
  }
  goto done;

cleanupBletch:
  deleteBar( foo->bar );
  // fall through to clean up the rest

cleanupBar:
  free( foo );
  foo = NULL;

done:
  return foo;
}

void deleteFoo( Foo *f )
{
  deleteBar( f->bar );
  deleteBletch( f->bletch );
  free( f );
}
    
por 16.06.2016 / 21:28
fonte
2

Eu aprendi a classificar problemas de memória em várias categorias diferentes.

  • Uma vez pinga. Suponha que um programa vaze 100 bytes no tempo de inicialização, apenas para nunca vazar novamente. Perseguir e eliminar esses vazamentos únicos é bom (eu gosto de ter um relatório limpo por uma capacidade de detecção de vazamentos), mas não é essencial. Às vezes, há problemas maiores que precisam ser atacados.

  • Vazamentos repetidos. Uma função que é chamada repetidamente durante o decorrer de uma vida útil de programas que regularmente vaza memória um grande problema. Essas gotas vão torturar o programa e possivelmente o sistema operacional até a morte.

  • Referências mútuas. Se os objetos A e B fizerem referência um ao outro por meio de ponteiros compartilhados, você terá que fazer algo especial, seja no design dessas classes ou no código que implementa / usa essas classes para quebrar a circularidade. (Isso não é um problema para idiomas coletados pelo lixo.)

  • Lembrando muito. Este é o primo malvado de vazamentos de lixo / memória. O RAII não ajudará aqui, nem a coleta de lixo. Este é um problema em qualquer idioma. Se alguma variável ativa tiver um caminho que a conecte a algum pedaço aleatório de memória, esse pedaço aleatório de memória não será lixo. Fazer um programa tornar-se esquecido pode ser executado por vários dias é complicado. Fazer um programa que pode ser executado por vários meses (por exemplo, até o disco falhar) é muito, muito complicado.

Eu não tive um problema sério com vazamentos por um longo, longo tempo. Usar o RAII em C ++ ajuda muito a resolver essas falhas e vazamentos. (No entanto, é preciso ter cuidado com os indicadores compartilhados.) Muito mais importante, tive problemas com aplicativos cujo uso de memória continua crescendo, crescendo e crescendo por causa de conexões não-consolidadas com a memória que não são mais úteis.

    
por 16.06.2016 / 22:29
fonte
-6

Cabe ao programador C ++ implementar sua própria forma de coleta de lixo quando necessário. Não fazer isso resultará no que é chamado de 'vazamento de memória'. É bastante comum que as linguagens de 'alto nível' (como Java) tenham sido construídas na coleta de lixo, mas linguagens de 'baixo nível' como C e C ++ não.

    
por 16.06.2016 / 19:36
fonte