Home .NET .NET Framework.Memory management

.NET Framework.Memory management

by admin

This article discusses some points about memory management in the .NET Framework. The article describes how GC works, how GC controls its own heap, the modes of operation of GC. Examples are given on how to bypass GC for memory usage. I have presented not only the easily accessible information, but also the information which is available only from studying the dumps of the applications written in .NET. I hope the article is informative and not too 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, which is not necessarily reflected in the physical memory of the process. On 32-bit operating systems, 4Gb of virtual address space is allocated to processes. The default is 2Gb per user mode. We will focus on the user mode and not the kernel mode.
When a process needs to allocate memory, it first has to reserve it, then it has to commit the memory. This reserving/fixing process can be done 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 :

  • Streams (and stacks);
  • dll files;
  • Virtual memory allocations;
  • NT Hip;
  • Memory manager hips, such as .NET GC.

Controlled memory is allocated in parts. Memory allocations can be 16, 32, and 64Kb each. 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 perform garbage collection, there is not enough memory for the internal garbage collector structures, the process will crash.
You must examine the application and avoid even non-fatal OutOfMemoryException exceptions, because the process can 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 when managing memory, such as small fragmentation, large block redundancy.
Before a process can use memory, it must reserve and commit at least part of the memory. These 2 steps can be done with the API functions VirtualAlloc , VirtualAllocEx You can free up memory with VirtualFree or VirtualFreeEx Calling the latter functions will change the state of the memory blocks from "committed" to "free".
Stealing ideas from c++ developers. There are situations when it is impossible to use GC (we will talk about it later) due to intensive work with memory. Such situations are rare and arise for specific constraints. In this case we can implement malloc and free. Malloc makes a call to HeapAlloc , and free is a call to HeapFree You can create your own heap by calling HeapCreate A complete list of memory functions in the Windows environment can be found at Memory Management Functions
I’m not a linux developer, so I can’t say how to replace these API function calls. For those who will also need to use these functions, I suggest you implement this using the Abstract factory pattern even if you don’t intend to port your application to the Mono platform in the near future. Using these functions in general is not very correct, because it causes some portability problems, but in some very specific situations, to reduce the pressure on the GC you have to use this.

Controlled memory

The process memory consists of :

  • Managed heaps – saving all managed objects that GC has not yet collected;
  • Loader Hips – dynamic modules and saving for JIT compiled objects, such as method tables, method descriptions and EEClass (Next article will focus on JIT and its structures);
  • Native hips – hips for native memory;
  • Memory used for threads, their stacks and registers;
  • Memory used for storing native dll files, as well as for native parts of managed dll files;
  • Other virtual memory allocations that do not fit into any of the categories described above;
  • Memory allocations not yet in use.

Well, here we come to the GC heap. The garbage collector (GC) allocates and frees memory for managed code. GC uses VirtualAlloc to reserve a piece of memory for its heap. The size of the HIP depends on the GC mode, and the version of the .NET Framework. The size can be 16, 32, or 64MB. GC Hip is an inseparable block of virtual memory isolated from other process hips and managed by .NET Runtime. Interestingly, GC does not commit the entire memory block at once, but only as it grows. GC keeps track of the next free address at the end of the managed heap, and requests the next block of memory, if needed, starting from it. A separate hip is created for large objects (.NET Framework 2.0, in 1.1 large objects are in the same hip as generations, but in a different segment). A big object is an object larger than 85000 bytes.
Let’s focus on how GC works. GC uses 3 generations (0, 1, 2) and LOH (Heap for large objects). Created objects go to generation zero. As soon as the size of the zero generation reaches a threshold value (there is no more memory for it in the segment) and creation of a new object is impossible, garbage collection starts in the zero generation. If there is a shortage of memory in the first generation segment, garbage collection will be for the first generation, and for the zero generation. If there is garbage collection for generation 2, there will also be garbage collection for generation 1 and zero.
Objects that survive the zero generation garbage collection go to the first generation, from the first generation to the second generation. It is strongly not recommended to call the garbage collection manually, as it can affect the performance of your application (looking for examples when calling the garbage collection is correct, please write in comments to discuss this issue).
There is no generation for large objects. In the new .NET Framework since 1.1, if you delete an object that is at the end of a HIP, it causes the Private Bytes of the process to be reduced.
Very interesting question about how GC works – What actually happens during garbage collection? There are several steps GC performs regardless of whether garbage collection occurs, for generation 0, 1, 2 or full garbage collection. So, the steps of garbage collection are :

  • Initial GC stage – waits until all managed threads reach a point in execution when it is safe to suspend them;
  • Objects that are not referenced are marked as ready to be deleted;
  • GC plans segment sizes for generations and estimates the level of fragmentation in the hip after garbage collection is done;
  • Deletes objects marked for deletion. Writes those object addresses to the list of free space addresses if GC is non-compact;
  • Moves objects to the low-order addresses of the managed heap. The most expensive operation;
  • Resumption of controlled streams.

GC modes of operation:

  • Competing – designed for GUI applications where response time is important. Suspends application execution several times during garbage collection, giving it CPU time to execute. GC uses one hip and one thread;
  • Non-competitive – suspends the application until garbage collection is complete. GC uses one hip and one thread;
  • Server – maximum performance on a machine with multiple processors or cores. GC uses one chop per processor, and one thread per core.

How to enable the desired GC mode of operation. In the configuration file, the configuration/runtime section:

  • Competing – <gcConcurrent = true> ;
  • Uncompetitive – <gcConcurrent = false> ;
  • Server – <gcServer Enabled = true> .

Increasing GC performance comes down to solving the following problems :

  • Frequent and large object selection – forces GC to collect garbage more often. Frequent large object allocation can cause high CPU utilization because LOH garbage collection causes garbage collection in the second generation;
  • Allocating memory in advance – creates several problems. First, it makes GC collect garbage more often, and second, it allows objects to survive the build;
  • Many ancestors or pointers – when moving objects and reducing fragmentation all pointers will have to change, according to the new object addresses. Objects may be spread out to different sides of the segment in the heap when fragmentation is reduced. This can all have a negative effect on execution speed;
  • There are a lot of objects that manage to get to the next generation, and don’t live there long – objects that survive garbage collection get to the next generation, but don’t stay there long. They create pressure on the next generation, and can cause a costly garbage collection operation in that generation;
  • Objects that cannot be moved (GCHandleType.Pinned, Interop, fixed) – Increases the fragmentation of generation memory and GC may take longer to find a continuous memory area for a new object.

GC uses references to determine if it is possible to free the memory occupied by an object. Before doing garbage collection, GC starts with the ancestors and goes up through the references, building them as a tree. Using a list of references to all objects, it identifies objects that are inaccessible and ready for garbage collection.
There are several types of references :

  • Strong reference. An existing reference to a "live" object. This prevents the object from garbage collection;
  • Weak reference. Existing reference to a "live" object, but allowing to delete that object, which is referenced during garbage collection. This type of reference can be used for caching system, for example.

I wanted to write about finalization, but there is a lot of information about it. The only thing you can note is that each process adds an additional finalization thread, and the process can be suspended if you use locks to block this thread, and crashing the finalization thread leads since .NET Framework 2.0 to crash the entire application, in previous versions will restart the thread.

A few examples of working with memory without GC

using System;using System.Runtime.ConstrainedExecution;using System.Runtime.InteropServices;using System.Security;namespace TestApplication{#region NativeMethodsinternal 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 VirtualMemoryHandleinternal 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 HeapHandleinternal 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#endregionclass Program{static void Main(){IntPtr memoryChunkSize = (IntPtr)(1024 * 1024);IntPtr stackAllocation = (IntPtr)(1024);#region Example 1Console.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 2Console.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 3Console.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 4Console.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"));}#endregionConsole.ReadKey();}}}

You may also like