This article discusses some of the points about working with memory in the .NET Framework. The article describes the work of the GC, how the GC controls its hip, modes of operation of the GC. Examples are given for using memory around GC. I outlined not only easily accessible information, but also that which is available only when studying dumps of applications written in .NET. I hope the article turned out informative and not very boring. The next article will be about the loader, JIT, and its data structures, such as Method Tables, Method Descriptors, and EEClass.
Theory
Unmanaged memory
Virtual memory is a logical representation of memory that does not necessarily affect the physical memory of the process. On 32-bit operating systems, 4Gb of virtual address space is allocated to processes. The default is 2Gb on user mode. We focus on user mode, not kernel mode.
When a process needs to allocate memory, it must first reserve it, then it must fix the memory. This backup / commit process can be performed in one or two steps, depending on how you use the API to manipulate virtual memory.
Usually reserved memory is divided into the following parts:
- Threads (and stacks);
- .Dll files;
- Virtual memory allocation;
- NT hip;
- Heap memory managers such as .NET GC.
Managed memory is allocated in parts. Allocations of memory can be on 16, 32 and 64Kb. They must be allocated in uninterrupted blocks, and if there are not enough memory regions to allocate, the process throws an OutOfMemoryException. If the process cannot complete garbage collection, there is not enough memory for the internal structures of the garbage collector, the process will crash.
You must examine the application and avoid even non-fatal OutOfMemoryException exceptions, because the process may be in an unstable state after this exception.
The role of user mode memory managers is to manage virtual memory reservations. Memory managers use several algorithms to achieve different goals in memory management, such as small fragmentation, redundancy of large blocks.
Before a process can use memory, it must reserve and commit at least part of the memory. These 2 steps can be performed using the
VirtualAlloc API,
VirtualAllocEx . You can free up memory using
VirtualFree or
VirtualFreeEx . Calling the last functions will change the state of the memory blocks from “fixed” to “free”.
We steal ideas from developers on c ++. There are situations when using GC (we will talk about it later) for reasons of intensive work with memory is not possible. Such situations are rare, and arise with specific limitations. In this case, you can implement malloc and free. Malloc makes a call to
HeapAlloc , and free is a call to
HeapFree . You can create your own hip by calling
HeapCreate . A complete list of functions for working with memory in the Windows environment can be found at -
Memory Management Functions .
I am not a linux developer, so I cannot say what to replace the calls to these API functions. Those who will also need to use these functions, I suggest to implement it using the
Abstract factory pattern pattern , even if you are not going to transfer your application in the near future to the Mono platform. The use of these functions is generally not very correct, as it creates some difficulties with portability, but in some very specific situations, in order to reduce the pressure on the GC, you have to use it.
Managed memory
The process memory consists of:
- Managed Hips - save all managed objects that GC has not yet assembled;
- The bootloader headers are dynamic modules and storage for JIT compiled objects, such as method tables, method descriptions and EEClass (The next article will be devoted to JIT and its structures);
- Native heaps - heaps for native memory;
- Memory used for streams, their stacks and registers;
- Memory for storing native dll files, as well as for the native parts of managed dll files;
- Other virtual memory allocations are not suitable for one category described above;
- Not yet used areas of memory.
Well, here we come to the GC hip. The garbage collector (GC) allocates and frees memory for managed code. GC uses VirtualAlloc to reserve memory for its hip. The heap size depends on the GC mode, and the version of the .NET Framework. The size can be 16, 32 or 64Mb. Hip GC is an inseparable block of virtual memory isolated from other process heaps and managed by the .NET Runtime. Interestingly, the GC does not immediately capture the entire section of memory, but only as it grows. The GC tracks the next free address at the end of the managed heap, and requests the next block of memory, if necessary, starting with it. A separate heap is created for large objects (the .NET Framework 2.0, in 1.1 large objects are in the same heap as the generations are in, but in a different segment). Large object - an object larger than 85000 bytes.
Focus on the work of the GC. GC uses 3 generations (0, 1, 2) and LOH (Hip for large objects). Created objects fall into the zero generation. As soon as the size of the zero generation reaches the threshold value (there is no more memory for it in the segment) and the creation of a new object is not possible, in the zero generation the garbage collection begins. If there is a shortage of memory in the first generation segment, the garbage collection will be for the first generation, and for zero. When collecting garbage for the 2nd generation, there will also be garbage collection for the first and zero generation.
Objects survived after the generation of garbage in the zero generation go to the first, from the first to the second. Based on the foregoing, it is highly not recommended to manually call garbage collection, as this can greatly affect the performance of your application (looking for examples when the garbage collection call is correct, please write in a comment to discuss this issue).
For large objects there are no generations. In the new .NET framework starting from 1.1, if an object that is at the end of the heap is deleted, the Private Bytes process is reduced.
Very interesting issue of the GC - What actually happens when garbage collection? GC performs several steps regardless of whether garbage collection occurs, for 0, 1, 2 generations, or full garbage collection. So, the stages of garbage collection:
- The initial stage of the GC - waits until all managed flows reach a point in execution, when it is safe to suspend them;
- Objects that have no links are marked as ready for deletion;
- GC plans segment sizes for generations and assesses the level of fragmentation in the heap after performing garbage collection;
- Removes items marked for deletion. Enters these object addresses into the list of free space addresses if the GC is non-compact;
- Moves objects to lower addresses of a managed heap. The most expensive operation;
- Resume managed threads.
Modes of operation GC:
- Competing - created for GUI applications when response time is important. Suspends the execution of an application several times during the garbage collection process giving it processor time to execute. GC uses one hip and one stream;
- Non Compliant — Suspends the application until the garbage collection is complete. GC uses one hip and one stream;
- Server - maximum performance on a machine with multiple processors or cores. GC uses one hip per processor, as well as one thread per core.
How to enable the desired mode GC. In the configuration file, the configuration / runtime section:
- Competing - <gcConcurrent = true>;
- Noncompetitive - <gcConcurrent = false>;
- Server - <gcServer Enabled = true>.
Increasing GC performance comes down to solving the following problems:
- Frequent and large allocation of objects - causes the GC to collect garbage more often. Frequent allocation of large objects can cause a large processor load, since the LOH garbage collection causes the second generation garbage collection;
- Allocating memory in advance - creates several problems. Firstly, it causes GC to collect garbage more often, and secondly, it allows objects to survive the assembly;
- Many ancestors or pointers - when moving objects and reducing fragmentation, all pointers will have to be changed, in accordance with the new addresses of objects. Objects can be at reduced fragmentation are spaced apart in different directions of the segment in the heap. All this can negatively affect the speed of execution;
- A lot of objects that have time to get into the next generation, and do not live there for long - objects that survive the garbage collection, get into the next generation, but do not stay there for a long time. They put pressure on the next generation, and can cause costly garbage collection operations in this generation;
- Objects that cannot be moved (GCHandleType.Pinned, Interop, fixed) - increases the fragmentation of the memory of generations and the GC can search for a contiguous area of ​​memory for a new object longer.
GC uses links to determine if it is possible to free up memory occupied by an object. Before you perform garbage collection, GC begins with ancestors and goes up the links building them in the form of a tree. Using a list of links to all objects, it determines objects that are inaccessible and ready for garbage collection.
There are several types of links:
- Strong link. Existing reference to a “live” object. This prevents the object from garbage collection;
- Weak link The existing link to a “live” object, but allowing you to remove the object that is referenced during garbage collection. This type of links can be used for example for a caching system.
I wanted to write about the finalization, but the information on it is complete. The only thing to note is that an additional stream of finalization is added to each process, and the process can be suspended, if blocking this stream is blocked, and the crash of the finalization stream leads to the crash of the entire application starting from .NET Framework 2.0, in previous versions there will be a restart of the stream.
Some examples of working with memory without GC
using System; using System.Runtime.ConstrainedExecution; using System.Runtime.InteropServices; using System.Security; namespace TestApplication { #region NativeMethods internal static class NativeMethods { #region Virtual memory #region VirtualAlloc [Flags()] public enum AllocationType : uint { MEM_COMMIT = 0x1000, MEM_RESERVE = 0x2000, MEM_RESET = 0x80000, [Obsolete("Windows XP/2000: This flag is not supported.")] MEM_LARGE_PAGES = 0x20000000, MEM_PHYSICAL = 0x400000, MEM_TOP_DOWN = 0x100000, [Obsolete("Windows 2000: This flag is not supported.")] MEM_WRITE_WATCH = 0x200000, } [Flags()] public enum MemoryProtection : uint { PAGE_EXECUTE = 0x10, PAGE_EXECUTE_READ = 0x20, PAGE_EXECUTE_READWRITE = 0x40, [Obsolete("This flag is not supported by the VirtualAlloc or VirtualAllocEx functions. It is not supported by the CreateFileMapping function until Windows Vista with P1 andWindows Server 2008.")] PAGE_EXECUTE_WRITECOPY = 080, PAGE_NOACCESS = 0x01, PAGE_READONLY = 0x02, PAGE_READWRITE = 0x04, [Obsolete("This flag is not supported by the VirtualAlloc or VirtualAllocEx functions.")] PAGE_WRITECOPY = 0x08, PAGE_GUARD = 0x100, PAGE_NOCACHE = 0x200, PAGE_WRITECOMBINE = 0x400, } [DllImport( "kernel32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.Winapi)] internal static extern IntPtr VirtualAlloc( IntPtr lpAddress, IntPtr dwSize, AllocationType flAllocationType, MemoryProtection flProtect); #endregion #region VirtualFree [Flags()] public enum FreeType : uint { MEM_DECOMMIT = 0x4000, MEM_RELEASE = 0x8000, } [DllImport( "kernel32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.Winapi)] [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool VirtualFree( IntPtr lpAddress, IntPtr dwSize, FreeType dwFreeType); #endregion #endregion #region Heap #region HeapCreate [Flags()] public enum HeapOptions : uint { Empty = 0x00000000, HEAP_CREATE_ENABLE_EXECUTE = 0x00040000, HEAP_GENERATE_EXCEPTIONS = 0x00000004, HEAP_NO_SERIALIZE = 0x00000001, } [DllImport( "kernel32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.Winapi)] internal static extern IntPtr HeapCreate( HeapOptions flOptions, IntPtr dwInitialSize, IntPtr dwMaximumSize); #endregion #region HeapDestroy [DllImport( "kernel32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.Winapi)] [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool HeapDestroy( IntPtr hHeap); #endregion #region HeapAlloc [Flags()] public enum HeapAllocFlags : uint { Empty = 0x00000000, HEAP_GENERATE_EXCEPTIONS = 0x00000004, HEAP_NO_SERIALIZE = 0x00000001, HEAP_ZERO_MEMORY = 0x00000008, } [DllImport( "kernel32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.Winapi)] internal static extern unsafe void* HeapAlloc( HeapHandle hHeap, HeapAllocFlags dwFlags, IntPtr dwBytes); #endregion #region HeapFree [Flags()] public enum HeapFreeFlags : uint { Empty = 0x00000000, HEAP_NO_SERIALIZE = 0x00000001, } [DllImport( "kernel32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.Winapi)] [return: MarshalAs(UnmanagedType.Bool)] internal static extern unsafe bool HeapFree( HeapHandle hHeap, HeapFreeFlags dwFlags, void* lpMem); #endregion #endregion } #endregion #region Memory handles #region VirtualMemoryHandle internal sealed class VirtualMemoryHandle : SafeHandle { public VirtualMemoryHandle(IntPtr handle, IntPtr size) : base(handle, true) { Size = size; } [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] override protected bool ReleaseHandle() { return NativeMethods.VirtualFree( handle, Size, NativeMethods.FreeType.MEM_RELEASE); } public unsafe void* GetPointer(out IntPtr sizeOfChunk) { return GetPointer(IntPtr.Zero, out sizeOfChunk); } public unsafe void* GetPointer(IntPtr offset, out IntPtr sizeOfChunk) { if (IsInvalid || (offset.ToInt64() > Size.ToInt64())) { sizeOfChunk = IntPtr.Zero; return (void*)IntPtr.Zero; } sizeOfChunk = (IntPtr)(Size.ToInt64() - offset.ToInt64()); return (byte*)handle + offset.ToInt64(); } public unsafe void* GetPointer() { return GetPointer(IntPtr.Zero); } public unsafe void* GetPointer(IntPtr offset) { if (IsInvalid || (offset.ToInt64() > Size.ToInt64())) { return (void*)IntPtr.Zero; } return (byte*)handle + offset.ToInt64(); } public override bool IsInvalid { get { return handle == IntPtr.Zero; } } public IntPtr Size { get; private set; } } #endregion #region HeapHandle internal sealed class HeapHandle : SafeHandle { public HeapHandle(IntPtr handle) : base(handle, true) { } [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] override protected bool ReleaseHandle() { return NativeMethods.HeapDestroy(handle); } public unsafe void* Malloc(IntPtr size) { if (IsInvalid) { return (void*)IntPtr.Zero; } return NativeMethods.HeapAlloc( this, NativeMethods.HeapAllocFlags.Empty, size); } public unsafe bool Free(void* lpMem) { if (lpMem == null) { return false; } return NativeMethods.HeapFree( this, NativeMethods.HeapFreeFlags.Empty, lpMem); } public override bool IsInvalid { get { return handle == IntPtr.Zero; } } } #endregion #endregion class Program { static void Main() { IntPtr memoryChunkSize = (IntPtr)(1024 * 1024); IntPtr stackAllocation = (IntPtr)(1024); #region Example 1 Console.WriteLine("Example 1 (VirtualAlloc, VirtualFree):"); IntPtr memoryForSafeHandle = NativeMethods.VirtualAlloc( IntPtr.Zero, memoryChunkSize, NativeMethods.AllocationType.MEM_RESERVE | NativeMethods.AllocationType.MEM_COMMIT, NativeMethods.MemoryProtection.PAGE_EXECUTE_READWRITE); using (VirtualMemoryHandle memoryHandle = new VirtualMemoryHandle(memoryForSafeHandle, memoryChunkSize)) { Console.WriteLine( (!memoryHandle.IsInvalid) ? ("Allocated") : ("Not allocated")); if (!memoryHandle.IsInvalid) { bool memoryCorrect = true; unsafe { int* arrayOfInt = (int*)memoryHandle.GetPointer(); long size = memoryHandle.Size.ToInt64(); for (int index = 0; index < size / sizeof(int); index++) { arrayOfInt[index] = index; } for (int index = 0; index < size / sizeof(int); index++) { if (arrayOfInt[index] != index) { memoryCorrect = false; break; } } } Console.WriteLine( (memoryCorrect) ? ("Write/Read success") : ("Write/Read failed")); } } #endregion #region Example 2 Console.WriteLine("Example 2 (HeapCreate, HeapDestroy, HeapAlloc, HeapFree):"); IntPtr heapForSafeHandle = NativeMethods.HeapCreate( NativeMethods.HeapOptions.Empty, memoryChunkSize, IntPtr.Zero); using (HeapHandle heap = new HeapHandle(heapForSafeHandle)) { Console.WriteLine( (!heap.IsInvalid) ? ("Heap created") : ("Heap is not created")); if (!heap.IsInvalid) { bool memoryCorrect = true; unsafe { int* arrayOfInt = (int*)heap.Malloc(memoryChunkSize); if (arrayOfInt != null) { long size = memoryChunkSize.ToInt64(); for (int index = 0; index < size / sizeof(int); index++) { arrayOfInt[index] = index; } for (int index = 0; index < size / sizeof(int); index++) { if (arrayOfInt[index] != index) { memoryCorrect = false; break; } } if (!heap.Free(arrayOfInt)) { memoryCorrect = false; } } else { memoryCorrect = false; } } Console.WriteLine( (memoryCorrect) ? ("Allocation/Write/Read success") : ("Allocation/Write/Read failed")); } } #endregion #region Example 3 Console.WriteLine("Example 3 (stackalloc):"); unsafe { bool memoryCorrect = true; int* arrayOfInt = stackalloc int[(int)stackAllocation.ToInt64()]; long size = stackAllocation.ToInt64(); for (int index = 0; index < size / sizeof(int); index++) { arrayOfInt[index] = index; } for (int index = 0; index < size / sizeof(int); index++) { if (arrayOfInt[index] != index) { memoryCorrect = false; break; } } Console.WriteLine( (memoryCorrect) ? ("Allocation/Write/Read success") : ("Allocation/Write/Read failed")); } #endregion #region Example 4 Console.WriteLine("Example 4 (Marshal.AllocHGlobal):"); unsafe { bool memoryCorrect = true; var globalPointer = Marshal.AllocHGlobal(memoryChunkSize); int* arrayOfInt = (int*)globalPointer; if (IntPtr.Zero != globalPointer) { try { long size = memoryChunkSize.ToInt64(); for (int index = 0; index < size / sizeof(int); index++) { arrayOfInt[index] = index; } for (int index = 0; index < size / sizeof(int); index++) { if (arrayOfInt[index] != index) { memoryCorrect = false; break; } } } finally { Marshal.FreeHGlobal(globalPointer); } } else { memoryCorrect = false; } Console.WriteLine( (memoryCorrect) ? ("Allocation/Write/Read success") : ("Allocation/Write/Read failed")); } #endregion Console.ReadKey(); } } }