C++ Dilinde Dinamik Bellek Yönetimi

Serkan Eser
Nettsi Bilişim Teknoloji A.Ş.
11 min readJul 8, 2020

Bu yazımızda C++ dilinde dinamik bellek yönetiminin nasıl yapıldığını inceleyecek, konuyla ilgili dil ve standart kütüphane içerisindeki araçları daha yakından tanımaya çalışacağız.

Dinamik Bellek Yönetimi Nedir?

Dinamik bellek yönetimi temelde bir prosesin adres alanının (address space) çalışma zamanındaki ihtiyaçlarına göre genişletilebilmesi ve istenildiğinde sisteme geri verilebilmesidir. Bu amaçla C dilinde, yakından bildiğimiz, standart malloc ve free fonksiyonları kullanılmaktadır. C++ dilinde ise bu fonksiyonlar yerine genellikle new ve delete operatörleri kullanılmaktadır. Dinamik olarak kullanılan bellek bölgesi heap veya free storage olarak isimlendirilmektedir.

new ve delete operatörleri

C++ dilinde kullanılan new ve delete operatörleri, adlarından da anlaşılabileceği gibi, aslında fonksiyon değil dilin içsel bir aracı yani operatördür. Basit bir örnek üzerinden bu operatörlerin kullanımını inceleyelim.

#include <iostream>using namespace std;class X
{
int i_;
public:
X() : i_{111} { cout << __PRETTY_FUNCTION__ << endl; }
~X() { cout << __PRETTY_FUNCTION__ << endl; }
int value() { return i_; }
};
int main()
{
X* x = new X;
cout << x->value() << endl;
delete x;
return 0;
}

Uygulamayı derleyip çalıştırdığımızda sırasıyla X sınıfının constructor ve destructor fonksiyonlarının çağrıldığını görüyoruz. new operatörü ile X türünden dinamik bir nesne oluşturulmakta ve sonrasında delete operatörü ile bu nesne sonlandırılmaktadır. Uygulama beklediğimiz değeri üretmektedir.

X::X()
111
X::~X()

Aynı işlemi malloc ve free fonksiyonları ile yapsaydık sınıfın constructor ve destructor fonksiyonları çağrılmayacaktı. Bu durumu aşağıdaki kod ile test edebiliriz.

int main()
{
X* x = (X*) malloc (sizeof (X));
cout << x->value() << endl;
free(x);
return 0;
}

new ve delete operatörlerinin direkt fonksiyon çağrılarından farkı constructor ve destructor fonksiyonlarının çağrılmasına neden olacak kod üretmeleridir. Peki, new ve delete operatörlerini kullandığımızda, gerçekte dinamik bellek edinme ve sonrasında bu alanı sisteme geri verme işlemlerini kim yapmaktadır? Aslında arkaplanda yine, malloc ve free benzeri, standart kütüphane çağrıları yapılmaktadır. Bu fonksiyonlar operator new ve operator delete olarak isimlendirilmiştir.

operator new fonksiyonu

operator new fonksiyonu standart kütüphane içerisinden global olarak tanımlanmış bir fonksiyondur. new operatörü varsayılan olarak operator new fonksiyonunu çağırmaktadır. operator new fonksiyonu new operatörü tarafından çağrılabildiği gibi direkt olarak da çağrılabilir.

#include <iostream>int main()
{
char* s1 = static_cast<char*> (malloc(16));
char* s2 = static_cast<char*> (operator new(16));
return 0;
}

Örnekteki her iki fonksiyon çağrısı sonuçları itibariyle eşdeğerdir.

operator new fonksiyonuyla bir sınıf türü için bellek tahsisatı yapıldığında, malloc fonksiyonunda olduğu gibi, sınıfın constructor fonksiyonu çağrılmaz.

#include <iostream>using namespace std;class X
{
public:
X() { cout << __PRETTY_FUNCTION__ << endl; }
~X() { cout << __PRETTY_FUNCTION__ << endl; }
};
int main()
{
X* x = (X*)operator new(sizeof(X));
return 0;
}

operator new fonksiyonu yerine new operatörü kullanmanın temel avantajı sınıf türleri için constructor fonksiyonunun çağrılmasıdır. Yani derleyici new operatörü ile karşılaştığında ilk olarak operator new fonksiyonunu çağırmakta ve sonrasında elde edilen alanın başlangıç adresini sınıfın constructor fonksiyonuna geçirmektedir.

#include <iostream>using namespace std;class X
{
public:
X() { cout << __PRETTY_FUNCTION__ << endl; }
~X() { cout << __PRETTY_FUNCTION__ << endl; }
};
int main()
{
X* x = new X;
return 0;
}

Yukarıdaki örnekteki new X ifadesi için derleyici aşağıdaki sembolik makine kodlarını üretmektedir.

movl    $1, %edi
.LEHB0:
call _Znwm@PLT
.LEHE0:
movq %rax, %rbx
movq %rbx, %rdi
.LEHB1:
call _ZN1XC1Ev

_Znwm sembolü operator new fonksiyonunun dekore edilmiş halidir. c++filt aracı ile herhangi bir sembole karşılık gelen ismi öğrenebiliriz.

$ c++filt -t _Znwm
operator new(unsigned long)

_ZN1XC1Ev sembolü de X sınıfının constructor fonksiyonuna karşılık gelmektedir. En yalın haliyle bir operator new fonksiyonu aşağıdaki gibi yazılabilir.

void* X::operator new(size_t size)
{
void* ptr = malloc(size);
if (!ptr) {
exit(EXIT_FAILURE);
}
return ptr;
}

GNU standart kütüphanesindeki operator new fonksiyonu ise aşağıdaki gibi tanımlanmıştır.

_GLIBCXX_WEAK_DEFINITION void *
operator new (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)
{
void *p;
/* malloc (0) is unpredictable; avoid it. */
if (__builtin_expect (sz == 0, false))
sz = 1;
while ((p = malloc (sz)) == 0)
{
new_handler handler = std::get_new_handler ();
if (! handler)
_GLIBCXX_THROW_OR_ABORT(bad_alloc());
handler ();
}
return p;
}

operator new içindeki diğer get_new_handler fonksiyonuna daha sonra bakacağız fakat şu aşamada aslında malloc fonksiyonunu kullandığını bilmemiz yeterli.

operator delete fonksiyonu

operator delete fonksiyonu da operator new gibi yine bir standart kütüphane fonksiyonudur ve delete operatörü tarafından çağrılmaktadır. operator delete fonksiyonu sınıf türleri için destructor fonksiyonu çağrısı oluşturmaz. operator delete GNU standart kütüphanesinde aşağıdaki gibi tanımlanmıştır.

_GLIBCXX_WEAK_DEFINITION void
operator delete(void* ptr) noexcept
{
std::free(ptr);
}

delete operatörü ise sınıf türleri için ilk olarak sınıfın destructor fonksiyonunu çağırmakta sonrasında ise ilgili bellek alanını, operator delete fonksiyonunu çağırarak, sisteme geri vermektedir.

#include <iostream>using namespace std;class X
{
public:
X() { cout << __PRETTY_FUNCTION__ << endl; }
~X() { cout << __PRETTY_FUNCTION__ << endl; }
};
int main()
{
X* x = new X;
delete x;
return 0;
}

delete x için aşağıdaki gibi bir kod üretildiğini varsayabiliriz.

x->~X();
operator delete(x);

Bir sınıfın destructor fonksiyonunun açık (explicit) olarak çağrılabildiğini hatırlayınız.

operator new[] ve operator delete[] fonksiyonları

Dizi türleri için new[] ve delete[] operatörleri kullanılmaktadır. Bu operatörler sırasıyla operator new[] ve operator delete[] fonksiyonlarını çağırmaktadır. Aşağıdaki örneği inceleyelim.

#include <iostream>using namespace std;class X
{
int i_;
public:
X() { cout << __PRETTY_FUNCTION__ << endl; }
~X() { cout << __PRETTY_FUNCTION__ << endl; }
};
int main()
{
X* x = new X[4];
delete[] x;
return 0;
}
X::X()
X::X()
X::X()
X::X()
X::~X()
X::~X()
X::~X()
X::~X()

operator new[] ve operator delete[] fonksiyonları, uygun sizeof değerleriyle, aslında yine operator new ve operator delete fonksiyonlarını çağırmaktadır. GNU standart kütüphanesi içindeki tanımları aşağıdaki gibidir.

_GLIBCXX_WEAK_DEFINITION void*
operator new[] (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)
{
return ::operator new(sz);
}
_GLIBCXX_WEAK_DEFINITION void
operator delete[] (void *ptr) _GLIBCXX_USE_NOEXCEPT
{
::operator delete (ptr);
}

new[] ve delete[] operatörlerinin new ve delete operatörlerinden farkı birden fazla constructor ve destructor çağrısına neden olmasıdır. Bu amaçla new[] operatörü operator new ile alınan bellek bölgesinin başına dizideki eleman sayısını yazmaktadır. Daha sonra delete[] bu bilgiyi kullanarak dizi içindeki tüm nesneler için destructor fonksiyonlarını çağırmaktadır. İlgilli makine komutları aşağıdaki gibidir.

movl    $24, %edi
.LEHB0:
call _Znam@PLT
.LEHE0:
movq %rax, %r12
movq $4, (%r12)

X türünün sizeof değeri 4 byte olmasına ve 4 elemanlı bir X dizisi için 16 byte gerekmesine karşın 24 byte yer ayrıldığını görüyoruz. Baştaki fazladan ayrılan 8 byte’lık alana dizinin uzunluğu yani 4 sayısı yazılmaktadır. delete[] operatorü bu bloğun başındaki bu bilgiyi kullanmaktadır. O yüzden new[] ile ayrılan bir alanı delete ile vermek tanımsız davranışa (undefined behaviour) sebep olmaktadır.

operator new ve operator delete fonksiyonlarının yüklenmesi

operator new ve operator delete fonksiyonları global ve sınıf düzeyinde yüklenebilir (overload).

#include <iostream>using namespace std;class X
{
int i_;
public:
X() { cout << __PRETTY_FUNCTION__ << endl; }
~X() { cout << __PRETTY_FUNCTION__ << endl; }
};
void* operator new(size_t size)
{
cout << __PRETTY_FUNCTION__ << endl;
cout << "size: " << size << endl;
void* ptr = malloc(size);
if (!ptr) {
exit(EXIT_FAILURE);
}
cout << "addr : " << ptr << endl;
return ptr;
}
void operator delete(void* ptr)
{
cout << __PRETTY_FUNCTION__ << endl;
cout << "addr : " << ptr << endl;
free(ptr);
}
int main()
{
X* x = new X;
delete x;
return 0;
}

operator new ve operator delete fonksiyonlarının global düzeyde tanımlanması durumunda new ve delete operatörleri kütüphane çağrısı yapmak yerine bu fonksiyonları kullanmaktadır.

void* operator new(size_t)
size: 4
addr : 0x55b870655280
X::X()
X::~X()
void operator delete(void*)
addr : 0x55b870655280

operator new ve operator delete fonksiyonlarını sınıf düzeyinde tanımlamak da mümkündür. Bu sayede bir sınıfa ait bellek edinme stratejisi belirlenebilir.

#include <iostream>using namespace std;class X
{
int i_;
public:
X() { cout << __PRETTY_FUNCTION__ << endl; }
~X() { cout << __PRETTY_FUNCTION__ << endl; }
void* operator new(size_t size);
void operator delete(void* ptr);
};
void* X::operator new(size_t size)
{
cout << __PRETTY_FUNCTION__ << endl;
cout << "size: " << size << endl;
void* ptr = malloc(size);
if (!ptr) {
exit(EXIT_FAILURE);
}
cout << "addr : " << ptr << endl;
return ptr;
}
void X::operator delete(void* ptr)
{
cout << __PRETTY_FUNCTION__ << endl;
cout << "addr : " << ptr << endl;
free(ptr);
}
int main()
{
X* x = new X;
delete x;

return 0;
}

Sınıfın operator new ve operator delete fonksiyonları açık bir şekilde yazılmasa dahi sınıfın statik üye fonksiyonları olarak ele alınmaktadır. Benzer şekilde operator new[] ve operator delete[] fonksiyonları da yüklenebilir.

İstenmesi durumunda, çözünülürlük operatörü kullanılarak, bellek edinme sınıfa özgü fonksiyonlarla değil yine global fonksiyonlarla yapılabilir.

int main()
{
X* x = ::new X;
::delete x;
return 0;
}

Bir sınıfın kendine özgü bellek edinmesiyle ilgili daha gerçekçi bir örnek aşağıda verilmiştir. Sınıf kendi içerisinde statik olarak edindiği bir bellek alanı (memory pool) tutmakta ve dinamik bellek edinme işlemlerini bu alan üzerinden yapmaktadır.

#include <iostream>using namespace std;class X 
{
char buf[16];
static char allocation_pool[];
static char allocation_map[];

public:
static const size_t max_object_size = 4;
X() { cout << __PRETTY_FUNCTION__ << endl; }
~X() { cout << __PRETTY_FUNCTION__ << endl; }
void *operator new(size_t);
void operator delete(void *);
};
char X::allocation_pool[max_object_size * sizeof(X)];
char X::allocation_map[max_object_size] = {0};
void *X::operator new(size_t)
{
char *ptr = (char*)memchr(allocation_map, 0, max_object_size);
if (!ptr) {
cout << "bellek yetersiz" << endl;
throw bad_alloc();
}
int block_no = ptr - allocation_map;
cout << block_no << " numaralı blok kullanılıyor" << endl;
*ptr = 1;
return allocation_pool + (block_no * sizeof(X));
}
void X::operator delete(void *ptr)
{
if (!ptr) {
return;
}
unsigned long block = reinterpret_cast<unsigned long>(ptr)
- reinterpret_cast<unsigned long>(allocation_pool);
block /= sizeof(X);
cout << block << " numaralı blok geri veriliyor" << endl;
allocation_map[block] = 0;
}
int main()
{
X *pa[X::max_object_size];
try {
for (size_t i = 0; i < X::max_object_size; ++i) {
pa[i] = new X;
}
}
catch (bad_alloc) {
cerr << "bellek yetersiz" << endl;
}
for (size_t i = 0; i < X::max_object_size; ++i) {
delete pa[i];
}

return 0;
}
0 numaralı blok kullanılıyor
X::X()
1 numaralı blok kullanılıyor
X::X()
2 numaralı blok kullanılıyor
X::X()
3 numaralı blok kullanılıyor
X::X()
X::~X()
0 numaralı blok geri veriliyor
X::~X()
1 numaralı blok geri veriliyor
X::~X()
2 numaralı blok geri veriliyor
X::~X()
3 numaralı blok geri veriliyor

X sınıfı allocation_pool isimli statik dizi içindeki, sizeof(X) genişliğindeki parçaları, X türünden nesneleri oluşturmak için kullanmaktadır. X sınıfı gerçekte bir malloc ya da free benzeri bir çağrıya sebep olmamaktadır. allocation_pool içindeki dolu ve boş bloklar allocation_map isimli ayrı bir dizi tarafından tutulmaktadır. allocation_map içinde allocation_pool içindeki herbir bloğa ilişkin 0 ya da 1 değerleri tutulmaktadır. 1 ilgili bloğun kullanımda olduğunu 0 ise boş olduğunu göstermektedir. operator new fonksiyonu allocation_pool içindeki ilk boş bloğun adresini dönmekte veya yer yok ise exception oluşturmaktadır. operator delete fonksiyonu da geri verilmek istenen adresin allocation_pool içinde yerini bulmakta ve bu alanı kullanılabilir olarak işaretlemektedir.

Yetersiz Bellek Durumu

malloc fonksiyonu bildiğimiz üzere başarısız olduğu yani yeterli belleği ayıramadığı durumda NULL değere dönmektedir. operator new fonksiyonu ise, daha farklı bir davranış sergilemekte, yetersiz bellek durumunda varsayılan olarak NULL değere dönmek yerine bad_alloc türünden bir hata nesnesi göndermektedir.

#include <iostream>using namespace std;class X
{
int buf[1024];
};
int main()
{
try {
X* ptr = new X[1024*1024*1024];
} catch (bad_alloc) {
cerr << "yetersiz bellek" << endl;
}
return 0;
}

operator new fonksiyonunun başarısızlık durumunda nullptr dönen başka bir yüklenmiş hali daha standart kütüphane içiresinde mevcuttur.

_GLIBCXX_WEAK_DEFINITION void *
operator new (std::size_t sz, const std::nothrow_t&) noexcept
{
__try
{
return ::operator new(sz);
}
__catch (...)
{
return nullptr;
}
}

new operatörüne nothrow_t türünden bir nesne geçirildiğinde bu fonksiyon çağırılmaktadır. nothrow_t türü aşağıdaki gibi tanımlanmıştır.

struct nothrow_t
{
#if __cplusplus >= 201103L
explicit nothrow_t() = default;
#endif
};

Standart kütüphane içerisinde aynı zamanda nothrow isimli nothrow_t türünden bir nesne de tanımlanmıştır.

extern const nothrow_t nothrow;

Bu durumda aşağıdaki örnekte new operatörü ile yeterli bellek ayrılamadığında geri dönüş değeri olarak nullptr dönmektedir.

#include <iostream>using namespace std;class X
{
int buf[1024];
};
int main()
{
X* ptr = new(nothrow) X[1024*1024*1024];
if (!ptr) {
cerr << "yetersiz bellek" << endl;
}
return 0;
}

placement new operatörü

Placement new operatörü dil içerisinde ilk bakışta fark edilen bir operatör değil fakat oldukça faydalı bir araç. Standart kütüphane içerisinde tanımlanan placement operator new fonksiyonları aşağıdaki gibidir.

// Default placement versions of operator new.
inline void* operator new(std::size_t, void* __p) _GLIBCXX_USE_NOEXCEPT
{ return __p; }
inline void* operator new[](std::size_t, void* __p) _GLIBCXX_USE_NOEXCEPT
{ return __p; }

Placement new operatörü bir bellek tahsisatı yapmaksızın direkt olarak kendisine geçirilen adresi döner. Sonrasında bu bellek bölgesi sınıfın constructor fonksiyonu çağrılarak ilklendirilebilir. Bu sayede bellek tahsisatı ile ilklendirme işlemleri birbirinden ayrılabilir.

#include <iostream>using namespace std;

int main()
{
char buf[64];
std::string* str = new(buf) string("hello");
cout << *str << endl;
str->~string();
return 0;
}

Örnekte std::string nesnesi 64 byte genişliğindeki buf ile gösterilen otomatik ömürlü bir alanda oluşturulmaktadır. Bellek tahsisatı ile ilklendirme işleminin ayrılması özellikle std::vector gibi koyteyner sınıfların tasarımında kullanılmaktadır. Başlangıçta bir bellek bölgesi alınmakta ve ancak ihtiyaç duyulduğunda ilklendirilmektedir. Standart kütüphane içinde placement new kullanımı allocator sınıfları üzerinden yapılmaktadır.

Ayrıca kullanıcı tarafından farklı parametrik yapılarda placement new fonksiyonları da yazılabilir. Aşağıda örnek bir kullanımı bulunmaktadır.

#include <iostream>using namespace std;void* operator new(size_t size, int, double, std::string)
{
cout << __PRETTY_FUNCTION__ << endl;
}
int main()
{
new(0, 0.0, {""}) char;
return 0;
}

set_new_handler ve get_new_handler fonksiyonları

Standart kütüphane içindeki operator new fonksiyonunun tanımına daha önce bakmıştık. operator new fonksiyonu bellek tahsisatı için malloc fonksiyonunu kullanmakta fakat kodda bir döngü görmekteyiz. Buradaki amaç malloc başarısız olduğunda direkt olarak bir bad_alloc nesnesi göndermek yerine kullanıcı tarafından belirlenebilen bir fonksiyonu çağırmaktır.

_GLIBCXX_WEAK_DEFINITION void *
operator new (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)
{
void *p;
/* malloc (0) is unpredictable; avoid it. */
if (__builtin_expect (sz == 0, false))
sz = 1;
while ((p = malloc (sz)) == 0)
{
new_handler handler = std::get_new_handler ();
if (! handler)
_GLIBCXX_THROW_OR_ABORT(bad_alloc());
handler ();
}
return p;
}

Kullanıcı hata durumunu işlemek için bir fonksiyonu kaydedebilir. Bu amaçla kütüphane için new_handler set_new_handler isimli bir fonksiyon bulunmaktadır. İlgili tür ve fonksiyonlar aşağıdaki gibidir.

typedef void (*new_handler)();
new_handler set_new_handler(new_handler) throw();
new_handler get_new_handler() noexcept;

Kullanıcı tarafından tanımlanan handler fonksiyonu yeni bir handler fonksiyonu kaydedebilir, dinamik bellek tahsisatının başarılı olabilmesi için başka bir strateji izleyebilir veya direkt olarak uygulamayı sonlandırabilir.

allocator kullanımı

Standart kütüphane içindeki konteyner türler bir alt seviye bellek işlemlerinden sorumlu, std::allocator uyumlu, bir allocator türü kabul etmektedir. Örnek olarak std::vector ve std::string sınıflarını verebiliriz.

template<
class T,
class Allocator = std::allocator<T>
> class vector;
template<
class CharT,
class Traits = std::char_traits<CharT>,
class Allocator = std::allocator<CharT>
> class basic_string;

Konteyner sınıflar bu allocator sınıfları kullanacak şekilde tasarlanmıştır. Bir allocator sınıf en temel düzeyde allocate, deallocate fonksiyonlarına ve value_type içsel türüne sahip olmalıdır. Konteyner sınıflar allocator türlerini direkt kullanmak yerine bir type traits olan std::allocator_traits üzerinden kullanmaktadır. Bu sayede bazı özellikler bu type traits üzerinden sağlanabilir.

En temel düzeyde bir allocator türü aşağıdaki gibi tanımlanabilir.

#include <iostream>
#include <vector>
using namespace std;template <class T>
struct basic_allocator
{
typedef T value_type;
basic_allocator() noexcept {}
T* allocate(std::size_t n) {
cout << __PRETTY_FUNCTION__ << endl;
return static_cast<T*>(::operator new(n*sizeof(T)));
}
void deallocate(T* p, std::size_t n) { ::delete(p); } template<class U, class... Args>
void construct(U* p, Args&&... args) {
cout << __PRETTY_FUNCTION__ << endl;
::new((void*) p) U(std::forward<Args>(args)...);
}
};
int main ()
{
std::vector<int, basic_allocator<int>> vec = {10, 20, 30};
for (auto el : vec) {
std::cout << el << " ";
}
std::cout << '\n';
return 0;
}

std::vector sınıfı artık dinamik bellek işlemerini basic_allocator türünü kullanarak yapacaktır. basic_allocator içerisinde bir construct fonksiyonu tanımlamak zorunda değildik, varsayılan olarak std::allocator_traits bizim için benzer bir construct fonksiyonu sağlamaktadır.

construct fonksiyonu içerisinde placement new operatörü kullanıldığına dikkat ediniz. Bu sayede allocate fonksiyonu ile tahsis edilmiş alan ilklendirilebilir. construct fonksiyonu içinde perfect forwarding yapılarak fazladan kopyalama maaliyetinden kaçınılmaktadır.

Özet

Bu yazımızda mümkün olduğunca gereksiz detaya girmeden C++ dilinde dinamik bellek yönteminin nasıl yapıldığına ve ilgili araçlara bakmaya çalıştık. Özetleyecek olursak;

  • new ve delete operatörleri operator new ve operator delete fonksiyonlarını çağırmaktadır.
  • operator new ve operator delete fonksiyonları global ya da sınıf düzeyinde yüklenebilir.
  • operator new fonksiyonunun hata durumunda exception üretmek yerine nullptr dönen bir versiyonu daha bulunmaktadır.
  • placement new operatörü kullanılarak bellek tahsisatı ve ilklendirme işlemleri birbirinden ayrılabilir. Bu sayede std::vector gibi koyteyner sınıflar daha performanslı şekilde tasarlanabilmektedir.
  • new operatörü ile gerekli yer tahsis edilemediğinde kullanıcının belirlediği, new_handler türünden, bir fonksiyon çağrılabilir.
  • Standart kütüphane içindeki konteyner sınıflar dinamik bellek işlemlerini bir allocator üzerinden yapacak şekilde tasarlanmışlardır. Bu sayede bu sınıflara özelleştirilmiş allocator türler geçirilebilir.

--

--