A Generic Object Pool in MQL5: Eliminating Heap Fragmentation in High-Frequency Indicators
Introduction
Every new operator call in MQL5 requests a contiguous block of memory from the terminal's managed heap. The allocator searches for a free block of sufficient size, marks it as occupied, and returns a pointer. Conversely, the delete operator marks that block as available again. These operations are fast in isolation. However, continuous high-frequency execution introduces overhead that accumulates over time.
The primary cost of repeated new/delete in OnCalculate() is not heap compaction — the specifics of the MQL5 terminal's internal allocator are not publicly documented to that level of detail — but rather the direct overhead of each allocation and deallocation call itself: constructor and destructor invocations, internal allocator bookkeeping, memory initialization, and timing jitter introduced into a hot execution path. Even if individual allocations are fast on average, the unpredictability of their cost matters in a trading context where latency consistency is often more valuable than raw average throughput.
Custom indicators executing OnCalculate() on every tick compound this overhead. During major news events, tick rates can exceed 100 per second. At that speed, the allocator performs two heap operations per tick alongside payload construction and destruction. This workload gradually increases execution time variance across a trading session.

Figure 1: Side-by-Side Memory State & Lookup Complexity (After 500 Cycles).
Object Pool Architecture
The pool implementation consists of three components. CSignalEvent is the reusable payload class representing a single signal computation result. CObjectPool<T> is the generic pool engine that manages the lifecycle of any poolable type. A custom indicator PoolBenchmark.mq5 exercises both components under simulated tick conditions and logs measurable timing differences between pooled and unpooled allocation strategies.
The pool operates on a free-list model. At construction, it allocates exactly m_capacity instances of type T on the heap and stores their pointers in a flat array. A second integer array of the same length records the indices of all currently available objects. An integer m_free_count tracks how many entries in that index array are valid.
Acquiring an object decrements m_free_count, reads the slot index stored at m_free_indices[m_free_count], and returns the pointer at m_objects[slot_index]. No heap call is made. Releasing an object reads the slot index stored inside the object itself via GetPoolIndex(), validates the pointer, calls Reset() on the payload fields, clears the in-use flag, and writes the slot index back to the free stack. Both Acquire() and Release() are O(1). No membership scan is required.
![CObjectPool internal memory layout showing the relationship between m_free_indices[] and m_objects[]. CObjectPool internal memory layout showing the relationship between m_free_indices[] and m_objects[].](https://c.mql5.com/2/223/NEWFigure_2_CObjectPool_Internals.png)
Figure 2: CObjectPool internal memory layout showing the relationship between m_free_indices[] and m_objects[]. The m_free_count cursor divides the index stack into in-use and available zones. Acquiring an object reads the target slot index and retrieves the corresponding pointer in O(1). Releasing an object reads the slot index stored inside the object itself, resets the payload, and returns the slot to the free stack in O(1).
Why a Free-List Index Array Rather Than a Pointer Stack
A simpler design would store the available pointers themselves in the free array rather than their indices. The index-based design is preferred for two reasons. First, it preserves the ability to validate that a released object actually belongs to this pool instance — the identity check m_objects[slot] == obj confirms this in O(1). Second, it keeps the free array as a plain int[] rather than a T*[], which avoids any pointer-type constraint on the pool's generic parameter and simplifies the ArrayResize operations needed during initialization.
Pool Capacity and Overflow Policy
The pool has a fixed capacity set at construction time. When the pool is exhausted, Acquire() returns NULL. The caller must handle this case. There is no fallback that allocates a new T() from the heap.

Figure 3: Decision flowcharts for Acquire() and Release(). Acquire() returns an object pointer in O(1) when slots are available, or NULL with a journal warning on exhaustion. Release() validates the pointer against double-release via IsInUse(), confirms pool ownership via IsPooled(), then recovers the slot in O(1) via GetPoolIndex() before resetting the payload and returning the slot to the free stack.
The CSignalEvent Payload Class
CSignalEvent represents a single signal computation result carrying a direction, a reference price, a strength value, and a timestamp. It implements the full pool contract that CObjectPool<T> requires.
Reset() clears only the business-state payload fields — m_direction, m_price, m_strength, and m_timestamp. Pool ownership metadata, specifically m_is_pooled, m_pool_index, and m_in_use, is managed exclusively by CObjectPool<T> and is never touched by Reset(). This separation is intentional. A payload class should have no knowledge of the allocator that owns it; embedding lifecycle decisions inside Reset() would couple business logic to memory management, making the class harder to reason about and reuse independently. The pool sets and maintains all ownership state from construction through the full acquire and release cycle.
CSignalEvent Property Table
| Property | Type | Default | Purpose |
|---|---|---|---|
| m_direction | int | 0 | Signal direction: 1 long, -1 short, 0 neutral |
| m_price | double | 0.0 | Reference price at signal generation time |
| m_strength | double | 0.0 | Normalized signal strength value (0.0 to 1.0) |
| m_timestamp | datetime | 0 | Server time at point of signal computation |
| m_is_pooled | bool | false | Pool ownership flag; set by CObjectPool<T> only |
| m_pool_index | int | -1 | Slot index inside pool; enables O(1) Release() |
| m_in_use | bool | false | In-use flag; prevents double-release corruption |
CSignalEvent Contract Methods
The constructor initializes payload fields via Reset() and sets pool metadata to safe defaults (m_is_pooled = false, m_pool_index = -1, m_in_use = false). CObjectPool<T> immediately overwrites these with correct values during construction of the pool itself.
Reset() zeros every payload field and nothing else:
//+------------------------------------------------------------------+ //| Reset business-state payload fields to defaults for pool reuse. | //| Does NOT touch m_is_pooled, m_pool_index, or m_in_use. | //| Pool ownership metadata is the pool's responsibility, not the | //| object's payload logic. | //+------------------------------------------------------------------+ void CSignalEvent::Reset(void) { m_direction = 0; m_price = 0.0; m_strength = 0.0; m_timestamp = 0; //--- m_is_pooled, m_pool_index, and m_in_use are intentionally NOT reset here. //--- Pool lifecycle metadata is managed exclusively by CObjectPool<T>. }
SetPooled() and IsPooled() record whether the object was pre-allocated by a pool. SetPoolIndex() and GetPoolIndex() carry the slot index used by Release() to return the object in O(1). SetInUse() and IsInUse() form the double-release guard: Release() checks IsInUse() before accepting the object and rejects it with a warning if the flag is already false.
The CObjectPool Template Engine
MQL5 supports class templates using the same template<typename T> syntax as C++. The template parameter T in CObjectPool<T> must be a class type that implements the full pool contract: Reset(), SetPooled(bool), IsPooled(), SetPoolIndex(int), GetPoolIndex(), SetInUse(bool), and IsInUse(). If T does not implement these methods, the compiler will emit an error at the point of instantiation rather than at the template definition. This is the expected behavior for MQL5 template classes.
CObjectPool Method Interface Table
| Method | Return Type | Access | Memory Operation |
|---|---|---|---|
| CObjectPool(int capacity) | — | public | Allocates all T instances on the heap; records slot indices; marks all as not-in-use |
| ~CObjectPool() | — | public | Deletes all T instances in m_objects[]; frees both arrays |
| Acquire() | T* | public | O(1): decrements m_free_count, sets in-use flag, returns pointer |
| Release(T* obj) | void | public | O(1): reads slot index from obj.GetPoolIndex(); validates against double-release; calls obj.Reset(); writes index to free stack |
| FreeCount() | int | public | Returns current m_free_count — no memory operation |
| Capacity() | int | public | Returns m_capacity — no memory operation |
| Utilization() | double | public | Returns (m_capacity − m_free_count) / m_capacity × 100.0 |
| IsExhausted() | bool | public | Returns m_free_count == 0 |
Constructor
The constructor performs all heap allocation for the lifetime of the pool. Both arrays are sized to m_capacity in a single ArrayResize call each. The loop instantiates every T object, stores its slot index inside the object via SetPoolIndex(), marks it as not-in-use and pool-owned, and records its index in the free stack. After the constructor returns, the heap allocator is not contacted again on the normal acquire/release path.
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ template<typename T> CObjectPool::CObjectPool(int capacity) : m_capacity(capacity), m_free_count(capacity) { ArrayResize(m_objects, m_capacity); ArrayResize(m_free_indices, m_capacity); //--- Instantiate all objects and record their slot indices for(int i = 0; i < m_capacity; i++) { m_objects[i] = new T(); m_objects[i].SetPoolIndex(i); // Store slot index inside the object (O(1) release) m_objects[i].SetInUse(false); // Mark as free at construction m_objects[i].SetPooled(true); // All pre-allocated objects belong to this pool m_free_indices[i] = i; } }
Acquire()
Acquire() is the O(1) path. When the pool has available slots, m_free_count is decremented, the slot index at m_free_indices[m_free_count] is read, and the pointer at m_objects[slot] is returned with the in-use flag set. No heap call is made. When the pool is exhausted, NULL is returned and a warning is printed. There is no heap fallback.
//+------------------------------------------------------------------+ //| Acquire an instance pointer from the pool | //| Returns NULL when pool is exhausted; caller must check. | //| No heap allocation occurs on this path after construction. | //+------------------------------------------------------------------+ template<typename T> T *CObjectPool::Acquire(void) { //--- O(1) pool path: return next available object from free stack if(m_free_count > 0) { m_free_count--; T *obj = m_objects[m_free_indices[m_free_count]]; obj.SetInUse(true); return(obj); } //--- Pool exhausted: caller must handle null //--- Do NOT fall back to new T() here: doing so reintroduces heap //--- allocation into the hot path, defeating the entire purpose of //--- the pool. Size the pool correctly in OnInit() instead. Print("[CObjectPool] WARNING: Pool exhausted. NULL returned. Increase pool capacity."); return(NULL); }
Release()
Release() handles four cases: a NULL pointer is silently ignored; a double-release (IsInUse() is false) is rejected with a warning; a pointer not owned by this pool (IsPooled() is false) is rejected with a warning; a valid owned object has its payload reset, its in-use flag cleared, and its slot returned to the free stack in O(1) via the stored index.
//+------------------------------------------------------------------+ //| Release an active object pointer back to the pool | //| O(1): uses the slot index stored inside the object itself. | //+------------------------------------------------------------------+ template<typename T> void CObjectPool::Release(T *obj) { if(obj == NULL) return; //--- Guard against double-release if(!obj.IsInUse()) { Print("[CObjectPool] WARNING: Double-release detected. Object was already free."); return; } //--- Only accept objects that belong to this pool instance if(!obj.IsPooled()) { Print("[CObjectPool] WARNING: Released pointer does not belong to this pool. Ignored."); return; } //--- Validate the stored slot index is in range int slot = obj.GetPoolIndex(); if(slot < 0 || slot >= m_capacity || m_objects[slot] != obj) { Print("[CObjectPool] WARNING: Invalid slot index on released object. Ignored."); return; } //--- Reset business-state payload (ownership metadata is NOT touched by Reset) obj.Reset(); obj.SetInUse(false); //--- Return slot to free stack in O(1) m_free_indices[m_free_count] = slot; m_free_count++; }
When to Use a Pool vs. Simpler Alternatives
An object pool is the right choice when: many objects of the same type must be alive simultaneously, their count is bounded and predictable, they are created and released at high frequency, and profiling has confirmed that allocation is a meaningful contributor to execution time.
For indicators that use only one or two objects per tick, a pool is unnecessary architecture. The simpler and equally effective solution is a pre-created global or class-member object reused every tick:
//--- Best option when only one object is needed per tick: CSignalEvent g_event; // global or class member - allocated once in OnInit() //+------------------------------------------------------------------+ //| Initialization | //+------------------------------------------------------------------+ int OnCalculate(...) { g_event.Reset(); g_event.SetDirection(...); g_event.SetPrice(...); //--- ... use g_event ... return(rates_total); }
This approach has zero allocation overhead, zero risk of pool exhaustion, no double-release scenario, and no additional contract methods to implement on the payload class. A pool should be considered only after the simpler reuse approach has been outgrown or profiled as insufficient.
The practical ranking of approaches, from simplest and most efficient to most complex:
- Local variables without an object — when the data is simple enough that an object adds no architectural value.
- One persistent object reused every tick — covers the vast majority of single-object-per-tick indicator patterns.
- A pre-allocated array of objects — when a fixed, small number of objects must coexist.
- An object pool — when a large, bounded number of identical short-lived objects are acquired and released at high frequency.
- new/delete on every tick — avoid this in OnCalculate() and OnTick().
Benchmark Design
The indicator runs two parallel calculation paths on every OnCalculate() call that represents a live incoming tick. The first path creates a CSignalEvent object via new, populates it, reads its fields into local variables, and immediately deletes it. The second path acquires a CSignalEvent from the pool, populates it identically, reads the same fields, and releases it back to the pool.
Both paths perform identical payload work. The only structural difference is the memory acquisition and release mechanism. The benchmark measures this difference, not signal computation cost.
Benchmark context: This is a synthetic micro-benchmark that deliberately isolates allocator overhead by running inp_iterations allocation cycles per tick. Real indicators performing a single object creation per tick will see a smaller absolute difference. The benchmark's value is as a demonstration that allocation overhead exists and is measurable — not as a proof of the magnitude of degradation in any specific production indicator. Profile your own indicator before committing to a pool.
On the first full recalculation pass (prev_calculated == 0), the indicator returns rates_total - 1 without running either benchmark path. Historical bars are left at EMPTY_VALUE. Once a live tick arrives, the current bar begins displaying measurements and updates on every subsequent tick. On a closed market or a slow symbol, the subwindow will remain blank until a live tick is received — this is the correct and intended behavior.
Timing uses GetMicrosecondCount(). The elapsed time for each path is validated against a plausibility range before being written to the buffers. Values below zero (counter reset) or above one million microseconds (system-level interruption) are rejected and the tick is skipped. This prevents transient anomalies from corrupting the session average accumulators.

Figure 4: Dual-path benchmark workflow for OnCalculate(). On the first pass, buffers are set to EMPTY_VALUE and no benchmark runs. On every live tick, both paths execute identical payload work on a CSignalEvent object — the unpooled path via new and delete, the pooled path via Acquire() and Release(). Elapsed microseconds from GetMicrosecondCount() are validated against a plausibility range before being written to the indicator buffers.
Indicator Buffer Implementation Notes
Three implementation details in PoolBenchmark.mq5 affect correct chart rendering and are worth documenting explicitly.
PLOT_EMPTY_VALUE must be EMPTY_VALUE, not 0.0: Setting the empty sentinel to 0.0 causes the chart engine to treat every zero-valued buffer slot — which covers the entire initialized history — as 'no data' and suppress drawing it. Since timing measurements on fast ticks can also legitimately round to values near zero, this caused the subwindow to remain permanently blank. The correct sentinel is EMPTY_VALUE (DBL_MAX), a value that can never appear as a real microsecond measurement. Buffers are initialized with ArrayInitialize(..., EMPTY_VALUE) to match.
ArraySetAsSeries must not be called on INDICATOR_DATA buffers: Indicator buffers bound via SetIndexBuffer(..., INDICATOR_DATA) are managed by the terminal in forward order: index 0 is the oldest bar, index rates_total-1 is the current bar. Calling ArraySetAsSeries(true) on them reverses the caller's index view but not the terminal's internal view, causing writes to land on the wrong bars and producing a blank plot. The fix is to remove all ArraySetAsSeries calls on indicator buffers and address the current bar as rates_total - 1.
DRAW_SECTION is preferred over DRAW_LINE for sparse live-tick data: DRAW_LINE connects every plotted point to the next with a straight line segment, including bridging the gap between the last EMPTY_VALUE historical slot and the first live measurement. On timeframes with infrequent ticks such as H1, this produces a near-vertical launch artifact that misrepresents the data. DRAW_SECTION draws lines only between consecutive non-empty bars and leaves true gaps elsewhere, giving an accurate visual of exactly which bars received live measurements.
Performance Expectations and Constraints
The per-tick timing difference between the two paths scales with tick frequency and allocator pressure. On a high-frequency symbol such as ETHUSD M1, where ticks arrive multiple times per minute, observed values were approximately 25 us unpooled versus 12 us pooled, yielding a ratio near 2.1x. The unpooled line also shows pronounced tick-to-tick variance — zigzagging between measurements — while the pooled line tracks more steadily below it. This variance is the jitter the pool eliminates, and is often more consequential in live trading than the average overhead difference alone.
On a lower-frequency symbol such as EURUSD H1, where ticks arrive minutes apart and the heap is under minimal pressure, observed values were approximately 4–13 us unpooled versus 2–7 us pooled. The ratio remains consistent at roughly 1.9–2.1x, but the absolute numbers are smaller because the allocator is working on a cold, largely unfragmented heap. This directly supports the point that for slow-tick symbols a single reused object is often sufficient and a pool provides negligible marginal benefit over the simpler alternative.
The Overhead Ratio line in the subwindow, which divides the unpooled time by the pooled time on each tick, is the clearest diagnostic. A ratio that rises over the course of a session indicates increasing allocator overhead that the pool eliminates on its path. A flat ratio across the session indicates the allocator is handling the object size and tick rate efficiently enough that either approach is acceptable.
Architectural Constraints
Thread safety: The pool is not thread-safe. MQL5 executes each indicator or EA instance on a single thread, which removes the need for a mutex. If future terminal updates introduce concurrent execution, Acquire() and Release() will require atomic operations on m_free_count.
Fixed sizing: The pool does not resize dynamically. Size it in OnInit() based on profiled maximum concurrent live objects. Add a small safety margin. If the pool is frequently exhausted in production (watch for the NULL warning in the journal), increase the capacity and reattach the indicator.
Memory overhead: The pool holds m_capacity instances of T for the entire indicator session. The total overhead is pool capacity × sizeof(CSignalEvent). For 64 objects this is negligible, but for very large pools or very large T types the fixed footprint should be considered
Conclusion
Repeated heap allocation in high-frequency indicator loops introduces overhead and timing jitter into OnCalculate(), where execution consistency matters more than raw average speed. The object pool pattern addresses this by confining all heap operations to the initialization phase and using O(1) index array operations for ongoing object lifecycle management.
The implementation rests on two core architectural decisions. First, an index-based free list rather than a pointer stack preserves pool membership validation while keeping the internal array type independent of the template parameter. Second, storing the slot index inside each object via SetPoolIndex() eliminates the need for a membership scan on release, making both Acquire() and Release() constant-time operations regardless of pool capacity.
The pool contract enforces a clean separation between business-state payload and lifecycle metadata. Reset() clears only payload fields, leaving ownership state exclusively under the pool's control. A per-object in-use flag guards against double-release corruption, and the fixed-capacity design with no heap fallback ensures the hot path remains allocation-free even under peak load.
The benchmark component provides ongoing diagnostic value in production. Its dual-path timing measurement quantifies whether a specific tick rate and object pattern justifies the pool's fixed memory footprint — equal to pool capacity multiplied by the size of the pooled type. A flat overhead ratio across a session indicates the allocator is handling the workload efficiently. A rising ratio or visible jitter on the unpooled line is the signal that the pool's consistency guarantee is worth the tradeoff.
Before adopting a pool, consider whether a single persistent object reset on each tick would be sufficient. For many indicators it will be. A pool earns its place when concurrent object demand is bounded, predictable, and high-frequency enough that allocation overhead has been measured as a meaningful contributor to execution time.
Programs used in the article:
| # | Name | Type | Description |
|---|---|---|---|
| 1 | SignalEvent.mqh | Include File | CSignalEvent poolable payload class carrying direction, price, strength, timestamp, plus pool contract fields (m_is_pooled, m_pool_index, m_in_use) with strict metadata/payload separation. |
| 2 | ObjectPool.mqh | Include File | CObjectPool<T> generic template engine: O(1) Acquire and O(1) Release via stored slot index, double-release protection, fixed capacity with no overflow fallback. |
| 3 | PoolBenchmark.mq5 | Custom Indicator | Dual-path benchmark measuring per-tick execution time between heap-allocated and pool-acquired CSignalEvent objects using GetMicrosecondCount(). Uses DRAW_SECTION for honest gap representation, EMPTY_VALUE as the plot sentinel, and rates_total-1 bar indexing without ArraySetAsSeries. |
| 4 | Generic_Object_Pool_in_MQL5.zip | Zip Archive | Zip archive containing all the attached files and their paths relative to the terminal's root folder. |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Features of Custom Indicators Creation
Market Microstructure in MQL5 (Part 5): Microstructure Noise
Features of Experts Advisors
CSV Data Analysis (Part 3): Engineering a Python Analytics Pipeline for MetaTrader 5 CSV Exports
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use