[C++] 항목 50: new 및 delete를 언제 바꿔야 좋은 소리를 들을지를 파악해 두자
카테고리: Cpp
태그: Cpp
이 글은 아래의 책을 자세히 정리한 후, 정리한 글을 GPT에게 요약을 요청하여 작성되었습니다.
이펙티브 C++ 제3판, 스콧 마이어스 저자, 곽용재 번역
📦 8. new와 delete를 내 맘대로
👉🏻 항목 50: new 및 delete를 언제 바꿔야 좋은 소리를 들을지를 파악해 두자
✅ operator new/delete를 바꾸는 이유
1. 잘못된 힙 사용을 탐지하기 위해
상황 1: new로 할당한 메모리를, 2번 이상 delete
→ 미정의 동작
- 해결법
- 할당된 메모리 주소 목록 변수를 둔다.
- operator new/delete에서 해당 목록 변수를 관리하도록 한다.
상황 2: 데이터 오버런/언더런
- 데이터 오버런: 할당된 메모리 블록을 넘어, 뒤에 기록하는 것
- 데이터 언더런: 할당된 메모리 블록을 넘어, 앞에 기록하는 것
// new는 정확히 5 * sizeof(int) = 20바이트 할당
int* p = new int[5];
p[5] = 100; // ❌ 오버런
p[-1] = 100; // ❌ 언더런
- 해결법
- 메모리의 앞뒤로 메모리를 조금 더 할당하고,
- 오버런/언더런 탐지용 바이트 패턴(시그니처)를 적어둔다.
- 그러면, 잘못된 값이 들어갔는지 확인할 수 있다.
2. 효율을 향상시키기 위해
- operator new/delete는 일반적인 쓰임새에 맞추어 설계되었다.
- 본인의 프로그램의 동적 메모리가 어떻게 사용될지 이해한다면,
메모리를 최적화 시킬 수 있다.
3. 동적 할당 메모리의 실제 사용에 관한 통계 정보를 수집하기 위해
수집 정보
- 할당된 메모리 블록의 크기는 어떤 분포를 보이는지?
- 사용 시간은 어떤 분포를 보이는지?
- 메모리가 할당되고 해제되는 순서가 FIFO인지, LIFO인지,
아니면 마구잡이 순서에 가까운지? - 시간 경과에 따라 메모리 사용 패턴이 바뀌는지?
→ 각 실행 단계마다 소프트웨어가 보이는 메모리 할당/해제 패턴이 차이를 보이는지? - 동적 할당 메모리의 최대량(최고 수위선)은 어느 정도인지?
✅ 데이터 오버런/언더런 해결
예시 코드
static const int signature = 0xDEADBEEF;
typedef unsigned char Byte;
// 고쳐야 할 부분이 있음
void* operator new(size_t size) throw(bad_alloc) {
size_t realSize = size + 2 * sizeof(int);
// 시그니처 2개를 앞뒤에 붙일수 있을만큼,
// 메모리 크기를 늘림
void* pMem = malloc(realSize);
if(!pMem) throw bad_alloc();
// 메모리 시작/끝 부분에 시그니처 기록
*(static_cast<int*>(pMem)) = signature;
*(
reinterpret_cast<int*>(
static_cast<Byte*>(pMem)+realsize-sizeof(int)
)
) = signature;
// 앞쪽 시그니처 바로 다음의 메모리를 가리키는 포인터 반환
return static_cast<Byte*>(pMem) + sizeof(int);
}
- 문제점
- operator new에는 new 처리자 함수를 호출하는 루프가 없음
(항목 51 참조) - 바이트 정렬 문제
- operator new에는 new 처리자 함수를 호출하는 루프가 없음
🗃️ 바이트 정렬
문제점 1: 아키텍처적으로 특정 타입의 데이터는
특정 종류 메모리 주소를 시작 주소로 하여 저장되어야 한다.
- 예로, 포인터는 4의 배수부터, double은 8의 배수부터 정렬해야 한다.
- 그렇지 않으면, 하드웨어 예외를 일으킬 수 있다.
- 인텔 x86 아키텍처는 바이트 단위로 정렬하면,
런타임 접근 속도가 훨씬 빨라진다.
문제점 2: 모든 operator new 함수는 어떤 데이터 타입에도
바이트 정렬을 만족하는 포인터를 반환해야 한다.
- malloc에서 얻은 포인터를 operator new가 바로 반환하는 것은 안전하다.
- 하지만, 위 코드에서는 int 크기만큼 뒤로 어긋난 주소를 포인터로 반환한다.
- 이러한 경우 프로그램이 다운되거나, 실행 속도가 느려질 수 있다.
❓ 굳이 만들어 써야 할까?
-
꼭 만들어 쓸 이유가 없다면, 굳이 필요가 없다.
- 시중 컴파일러 중에는 메모리 관리 함수에 디버깅 및 로깅 기능을 넣어 놓고,
필요에 따라 전환할 수 있도록 해둔 것도 있다. - 여러 플랫폼을 지원하는, 메모리 관리 함수 전문 상업용 제품도 있다.
- 여러 플랫폼을 지원하는, 메모리 관리자 패키지도 오픈 소스로 공개되어 있다.
- 이 중, 풀(Pool) 라이브러리가 있다.(항목 55 참조)
- 예로, 크기가 작은 객체(소형 객체)를 많이 할당할 경우 도움을 얻을 수 있다.
📌 총 정리: operator new/delete를 바꾸는 이유
1. 잘못된 힙 사용 탐지
- 메모리 오류(누수, 이중 해제, 오버런 등)를 조기에 감지하기 위해 사용한다.
→ 커스텀 헤더나 체크섬을 삽입하여 오류를 추적할 수 있다.
2. 동적 할당 통계 수집
- 실제 메모리 사용 패턴을 분석할 수 있다.
→ 할당/해제 빈도, 피크 사용량 등을 로깅하여 메모리 사용 효율을 파악한다.
3. 할당/해제 속도 향상
- 기본 범용 할당자는 범용성 + 스레드 안전성 때문에 상대적으로 느리다.
- 이를 개선하기 위해 다음과 같은 전략을 사용한다:
- 특정 객체 전용으로 최적화된 사용자 정의 할당자 사용
- 고정 크기 객체는
boost::pool같은 풀 할당자 사용 - 단일 스레드 앱에서는 락 없는 구현으로 성능 향상
✅ 단, 병목이 실제로 존재하는지 프로파일링을 통해 반드시 확인해야 한다.
4. 공간 오버헤드 감소
- 범용 할당자는 블록당 메타데이터와 패딩이 많아,
작은 객체가 많을 경우 오버헤드가 누적된다.
→ 튜닝된 할당자는 오버헤드를 1~4바이트 수준으로 줄일 수 있다.
5. 바이트 정렬 보장
- 일부 컴파일러는
double등 8바이트 정렬을 보장하지 않는다.
→ 사용자 정의 operator new를 통해 8B 또는 16B 정렬을 강제하면, 캐시 효율이 향상된다.
6. 메모리 군집화 (Locality)
- 관련 객체들을 물리적으로 인접하게 배치하면 페이지 폴트가 감소한다.
→ placement new/delete를 활용해 객체를 원하는 메모리 위치에 직접 배치한다.
7. 임의 동작 삽입
- 컴파일러가 제공하지 않는 부가 로직을 삽입할 수 있다.
- 공유 메모리 영역에 객체를 할당 (C API 연동)
delete시memset(0)으로 보안을 강화- 디버그 패턴 삽입으로 오류 추적성 향상
🧐 정리
- 개발자가 사용자 정의 new 및 delete를 작성하는 데는 여러 가지 타당한 이유가 있다.
메모리 오류 탐지, 성능 최적화, 통계 수집 등이 대표적이다. - 바이트 정렬은 반드시 고려해야 한다.
정렬 요구사항을 만족하지 못하면 프로그램이 다운되거나 성능이 저하될 수 있다. - 꼭 필요한 경우가 아니라면 기존 솔루션을 활용하자.
컴파일러 내장 기능, 상업용 제품, 오픈소스 라이브러리 등 검증된 대안이 많다. - 성능 최적화를 위해 사용자 정의 할당자를 만들 때는 반드시 프로파일링을 먼저 하자.
실제 병목이 메모리 할당에 있는지 확인하는 것이 중요하다.
댓글남기기