Ottimizzazione della Memoria in Java

Edoardo Midali
Edoardo Midali

L’ottimizzazione della memoria in Java richiede una comprensione profonda di come la JVM gestisce gli oggetti, delle strutture dati più efficienti e delle tecniche avanzate per ridurre il memory footprint. Questo processo è essenziale per applicazioni ad alte performance, sistemi con vincoli di memoria e ambienti cloud dove i costi sono proporzionali alle risorse utilizzate.

Principi di Ottimizzazione

L’ottimizzazione della memoria si basa su alcuni principi fondamentali che guidano le decisioni di design e implementazione.

Memory Footprint Reduction

Minimize Object Headers: Ogni oggetto Java ha un header (8-16 bytes) - ridurre il numero di oggetti diminuisce l’overhead.

Pack Data Efficiently: Organizzare dati per minimizzare padding e allineamento.

Use Primitive Collections: Evitare boxing/unboxing di primitive in collezioni standard.

Leverage String Interning: Riutilizzare istanze di stringhe comuni.

public class MemoryFootprintDemo {

    // INEFFICIENTE: Ogni coordinate è un oggetto separato
    static class InefficiatePoint {
        private Double x; // 16 bytes header + 8 bytes double = 24 bytes
        private Double y; // 16 bytes header + 8 bytes double = 24 bytes
        // Total per point: ~48 bytes + overhead
    }

    // EFFICIENTE: Dati packed in singolo oggetto
    static class EfficientPoint {
        private double x; // 8 bytes
        private double y; // 8 bytes
        // Total per point: 16 bytes + header object (16 bytes) = 32 bytes
    }

    // MOLTO EFFICIENTE: Array di primitive
    static class PointArray {
        private double[] coordinates; // x1, y1, x2, y2, ...

        public PointArray(int capacity) {
            this.coordinates = new double[capacity * 2];
        }

        public void setPoint(int index, double x, double y) {
            coordinates[index * 2] = x;
            coordinates[index * 2 + 1] = y;
        }

        public double getX(int index) { return coordinates[index * 2]; }
        public double getY(int index) { return coordinates[index * 2 + 1]; }

        // Overhead: solo un array + object header
        // Per 1000 punti: ~16KB vs ~48KB della versione inefficiente
    }

    public static void demonstrateFootprintDifference() {
        int numPoints = 10000;

        // Approccio inefficiente
        List<InefficiatePoint> inefficientPoints = new ArrayList<>();
        for (int i = 0; i < numPoints; i++) {
            inefficientPoints.add(new InefficiatePoint());
        }

        // Approccio efficiente
        PointArray efficientPoints = new PointArray(numPoints);
        for (int i = 0; i < numPoints; i++) {
            efficientPoints.setPoint(i, i * 1.0, i * 2.0);
        }

        System.out.println("Memory saved with efficient approach: ~66%");
    }
}

Locality of Reference

Organizzare dati per migliorare cache performance e ridurre miss rate.

public class LocalityDemo {

    // SOA (Structure of Arrays) - Migliore cache locality
    static class ParticleSystemSOA {
        private float[] positionsX;
        private float[] positionsY;
        private float[] positionsZ;
        private float[] velocitiesX;
        private float[] velocitiesY;
        private float[] velocitiesZ;

        public ParticleSystemSOA(int capacity) {
            positionsX = new float[capacity];
            positionsY = new float[capacity];
            positionsZ = new float[capacity];
            velocitiesX = new float[capacity];
            velocitiesY = new float[capacity];
            velocitiesZ = new float[capacity];
        }

        // Operazione che beneficia di cache locality
        public void updatePositions(float deltaTime) {
            for (int i = 0; i < positionsX.length; i++) {
                positionsX[i] += velocitiesX[i] * deltaTime;
                positionsY[i] += velocitiesY[i] * deltaTime;
                positionsZ[i] += velocitiesZ[i] * deltaTime;
            }
            // Accesso sequenziale agli array - ottimo per cache
        }
    }

    // AOS (Array of Structures) - Peggiore cache locality
    static class ParticleSystemAOS {
        static class Particle {
            float posX, posY, posZ;
            float velX, velY, velZ;
        }

        private Particle[] particles;

        public ParticleSystemAOS(int capacity) {
            particles = new Particle[capacity];
            for (int i = 0; i < capacity; i++) {
                particles[i] = new Particle();
            }
        }

        public void updatePositions(float deltaTime) {
            for (Particle p : particles) {
                p.posX += p.velX * deltaTime;
                p.posY += p.velY * deltaTime;
                p.posZ += p.velZ * deltaTime;
            }
            // Molti object accesses - peggio per cache
        }
    }
}

Data Structure Optimization

La scelta delle strutture dati appropriate può dramaticamente ridurre il memory footprint e migliorare le performance.

Primitive Collections

Le collezioni di primitive evitano boxing overhead e riducono significativamente l’uso di memoria.

// Usando librerie come Eclipse Collections o Trove
public class PrimitiveCollectionsDemo {

    public static void demonstrateBoxingOverhead() {
        int size = 1_000_000;

        // Standard Collections con boxing
        List<Integer> boxedList = new ArrayList<>();
        Map<Integer, Integer> boxedMap = new HashMap<>();

        // Ogni Integer boxed: 16 bytes header + 4 bytes int = 20 bytes
        // vs 4 bytes per primitive int

        for (int i = 0; i < size; i++) {
            boxedList.add(i); // Boxing overhead
            boxedMap.put(i, i * 2); // Boxing per key e value
        }

        // Memory usage: ~80MB per lista + ~160MB per mappa

        /*
        // Con librerie specializzate (esempio concettuale)
        IntList primitiveList = new IntArrayList();
        IntIntMap primitiveMap = new IntIntHashMap();

        for (int i = 0; i < size; i++) {
            primitiveList.add(i); // No boxing
            primitiveMap.put(i, i * 2); // No boxing
        }

        // Memory usage: ~4MB per lista + ~16MB per mappa
        // Risparmio: ~75% di memoria
        */
    }

    // Custom primitive array con crescita controllata
    static class IntArrayList {
        private int[] array;
        private int size;

        public IntArrayList(int initialCapacity) {
            array = new int[initialCapacity];
            size = 0;
        }

        public void add(int value) {
            if (size == array.length) {
                resize();
            }
            array[size++] = value;
        }

        private void resize() {
            // Crescita controllata per evitare spreco memoria
            int newCapacity = array.length + (array.length >> 1); // +50%
            array = Arrays.copyOf(array, newCapacity);
        }

        public int get(int index) {
            if (index >= size) throw new IndexOutOfBoundsException();
            return array[index];
        }

        public int size() { return size; }

        // Ottimizzazione: trim to size quando necessario
        public void trimToSize() {
            if (size < array.length) {
                array = Arrays.copyOf(array, size);
            }
        }
    }
}

Bit Packing e Compression

Tecniche per comprimere dati quando il range di valori è limitato.

public class BitPackingDemo {

    // Esempio: memorizzare età (0-127), genere (1 bit), stato (0-15)
    static class CompactPerson {
        private int packedData; // 32 bit per memorizzare tutte le info

        // Layout: [unused 17 bits][stato 4 bits][genere 1 bit][età 7 bits][reserved 3 bits]
        private static final int AGE_MASK = 0x7F;        // 7 bits per età
        private static final int GENDER_MASK = 0x80;     // 1 bit per genere
        private static final int STATUS_MASK = 0xF00;    // 4 bits per stato

        private static final int AGE_SHIFT = 0;
        private static final int GENDER_SHIFT = 7;
        private static final int STATUS_SHIFT = 8;

        public CompactPerson(int age, boolean isMale, int status) {
            setAge(age);
            setGender(isMale);
            setStatus(status);
        }

        public void setAge(int age) {
            if (age < 0 || age > 127) throw new IllegalArgumentException("Age must be 0-127");
            packedData = (packedData & ~(AGE_MASK << AGE_SHIFT)) |
                        ((age & AGE_MASK) << AGE_SHIFT);
        }

        public int getAge() {
            return (packedData >> AGE_SHIFT) & AGE_MASK;
        }

        public void setGender(boolean isMale) {
            if (isMale) {
                packedData |= (1 << GENDER_SHIFT);
            } else {
                packedData &= ~(1 << GENDER_SHIFT);
            }
        }

        public boolean isMale() {
            return (packedData & (1 << GENDER_SHIFT)) != 0;
        }

        public void setStatus(int status) {
            if (status < 0 || status > 15) throw new IllegalArgumentException("Status must be 0-15");
            packedData = (packedData & ~(STATUS_MASK)) |
                        ((status & 0xF) << STATUS_SHIFT);
        }

        public int getStatus() {
            return (packedData & STATUS_MASK) >> STATUS_SHIFT;
        }

        // Un singolo int vs 3 campi separati (12+ bytes)
        // Risparmio: ~75% per oggetti semplici
    }

    // BitSet per flag multipli
    static class FeatureFlags {
        private long flags; // 64 flag in un singolo long

        public void setFlag(int index, boolean value) {
            if (index < 0 || index >= 64) throw new IllegalArgumentException();

            if (value) {
                flags |= (1L << index);
            } else {
                flags &= ~(1L << index);
            }
        }

        public boolean getFlag(int index) {
            if (index < 0 || index >= 64) throw new IllegalArgumentException();
            return (flags & (1L << index)) != 0;
        }

        public int countSetFlags() {
            return Long.bitCount(flags);
        }
    }
}

Memory Mapping e Off-Heap Storage

Per dataset molto grandi, tecniche off-heap possono ridurre pressure sul GC.

Memory-Mapped Files

import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class MemoryMappingDemo {

    static class LargeDataProcessor {
        private final MappedByteBuffer mappedBuffer;
        private final FileChannel channel;

        public LargeDataProcessor(String filename, long size) throws IOException {
            RandomAccessFile file = new RandomAccessFile(filename, "rw");
            this.channel = file.getChannel();

            // Mappa file in memoria - OS gestisce paging
            this.mappedBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, size);
        }

        // Accesso diretto senza passare per heap Java
        public void writeInt(long position, int value) {
            mappedBuffer.putInt((int) position, value);
        }

        public int readInt(long position) {
            return mappedBuffer.getInt((int) position);
        }

        // Processamento di dati grandi senza impact sul GC
        public void processLargeDataset(int[] data) {
            for (int i = 0; i < data.length; i++) {
                writeInt(i * 4L, data[i] * 2); // Elabora e memorizza
            }

            // Force sincronizzazione con storage
            mappedBuffer.force();
        }

        public void close() throws IOException {
            // Unmap non è direttamente disponibile, ma GC lo gestirà
            channel.close();
        }
    }

    // Chronicle Map per off-heap key-value storage
    static class OffHeapCache {
        // Esempio concettuale - richiede libreria esterna
        /*
        private ChronicleMap<String, byte[]> offHeapMap;

        public OffHeapCache(long maxEntries, long maxValueSize) {
            offHeapMap = ChronicleMap
                .of(String.class, byte[].class)
                .entries(maxEntries)
                .averageValueSize(maxValueSize)
                .create();
        }

        public void put(String key, byte[] value) {
            offHeapMap.put(key, value); // Memorizzato off-heap
        }

        public byte[] get(String key) {
            return offHeapMap.get(key); // Letto da off-heap
        }
        */
    }
}

Unsafe Operations

Per casi estremi, operazioni unsafe permettono controllo diretto della memoria (deprecato in versioni recenti).

// ATTENZIONE: sun.misc.Unsafe è deprecated e non dovrebbe essere usato
// Questo è solo per scopi educativi
public class UnsafeDemo {

    /*
    // Esempio concettuale - NON usare in produzione
    static class DirectMemoryBuffer {
        private static final Unsafe unsafe = getUnsafe();
        private long address;
        private long size;

        public DirectMemoryBuffer(long size) {
            this.size = size;
            this.address = unsafe.allocateMemory(size);
            unsafe.setMemory(address, size, (byte) 0); // Zero initialize
        }

        public void putInt(long offset, int value) {
            if (offset + 4 > size) throw new IndexOutOfBoundsException();
            unsafe.putInt(address + offset, value);
        }

        public int getInt(long offset) {
            if (offset + 4 > size) throw new IndexOutOfBoundsException();
            return unsafe.getInt(address + offset);
        }

        public void free() {
            if (address != 0) {
                unsafe.freeMemory(address);
                address = 0;
            }
        }

        private static Unsafe getUnsafe() {
            // Reflection per accedere a Unsafe
            // NON fare questo in codice di produzione
        }
    }
    */
}

String Optimization

Le stringhe sono spesso una fonte significativa di memory usage nelle applicazioni Java.

String Interning e Deduplication

public class StringOptimizationDemo {

    public static void demonstrateStringInterning() {
        List<String> strings = new ArrayList<>();

        // Simulazione caricamento dati con stringhe duplicate
        String[] commonValues = {"ACTIVE", "INACTIVE", "PENDING", "COMPLETED"};

        for (int i = 0; i < 100000; i++) {
            // Senza interning: molte istanze duplicate
            String status = new String(commonValues[i % commonValues.length]);
            strings.add(status);
        }

        System.out.println("Before interning: " + strings.size() + " string objects");

        // Applica interning per ridurre duplicati
        for (int i = 0; i < strings.size(); i++) {
            strings.set(i, strings.get(i).intern());
        }

        System.out.println("After interning: substantial memory reduction");

        // JVM flag per string deduplication automatica (G1GC)
        // -XX:+UseG1GC -XX:+UseStringDeduplication
    }

    // String pool personalizzato per applicazioni specifiche
    static class StringPool {
        private final Map<String, String> pool = new ConcurrentHashMap<>();

        public String intern(String str) {
            if (str == null) return null;

            // Usa computeIfAbsent per thread safety
            return pool.computeIfAbsent(str, Function.identity());
        }

        public void clear() {
            pool.clear();
        }

        public int size() {
            return pool.size();
        }
    }

    // StringBuilder pooling per ridurre allocazioni temporanee
    static class StringBuilderPool {
        private final ThreadLocal<StringBuilder> pool = new ThreadLocal<StringBuilder>() {
            @Override
            protected StringBuilder initialValue() {
                return new StringBuilder(256);
            }
        };

        public StringBuilder acquire() {
            StringBuilder sb = pool.get();
            sb.setLength(0); // Reset per riutilizzo
            return sb;
        }

        public String buildAndRelease(StringBuilder sb) {
            String result = sb.toString();
            // StringBuilder rimane nel ThreadLocal per riutilizzo
            return result;
        }
    }
}

Cache Optimization

Implementazione di cache efficienti che bilanciano memory usage e performance.

Size-Limited Caches

public class CacheOptimizationDemo {

    // LRU Cache con limite di memoria invece che di elementi
    static class MemoryBoundedCache<K, V> {
        private final Map<K, Node<K, V>> map = new HashMap<>();
        private final long maxMemoryBytes;
        private long currentMemoryBytes = 0;

        private Node<K, V> head, tail;

        static class Node<K, V> {
            K key;
            V value;
            Node<K, V> prev, next;
            long memorySize;

            Node(K key, V value, long memorySize) {
                this.key = key;
                this.value = value;
                this.memorySize = memorySize;
            }
        }

        public MemoryBoundedCache(long maxMemoryBytes) {
            this.maxMemoryBytes = maxMemoryBytes;
            this.head = new Node<>(null, null, 0);
            this.tail = new Node<>(null, null, 0);
            head.next = tail;
            tail.prev = head;
        }

        public synchronized V get(K key) {
            Node<K, V> node = map.get(key);
            if (node == null) return null;

            moveToHead(node);
            return node.value;
        }

        public synchronized void put(K key, V value) {
            long memorySize = estimateMemorySize(key, value);

            Node<K, V> existing = map.get(key);
            if (existing != null) {
                existing.value = value;
                currentMemoryBytes = currentMemoryBytes - existing.memorySize + memorySize;
                existing.memorySize = memorySize;
                moveToHead(existing);
            } else {
                Node<K, V> newNode = new Node<>(key, value, memorySize);
                map.put(key, newNode);
                addToHead(newNode);
                currentMemoryBytes += memorySize;
            }

            // Evict fino a rientrare nel limite di memoria
            while (currentMemoryBytes > maxMemoryBytes && tail.prev != head) {
                evictTail();
            }
        }

        private void evictTail() {
            Node<K, V> last = tail.prev;
            removeNode(last);
            map.remove(last.key);
            currentMemoryBytes -= last.memorySize;
        }

        private void moveToHead(Node<K, V> node) {
            removeNode(node);
            addToHead(node);
        }

        private void addToHead(Node<K, V> node) {
            node.prev = head;
            node.next = head.next;
            head.next.prev = node;
            head.next = node;
        }

        private void removeNode(Node<K, V> node) {
            node.prev.next = node.next;
            node.next.prev = node.prev;
        }

        private long estimateMemorySize(K key, V value) {
            // Stima approssimativa - può essere raffinata
            long size = 64; // Object overhead

            if (key instanceof String) {
                size += ((String) key).length() * 2; // char[] interno
            } else {
                size += 32; // Stima per altri oggetti
            }

            if (value instanceof String) {
                size += ((String) value).length() * 2;
            } else if (value instanceof byte[]) {
                size += ((byte[]) value).length;
            } else {
                size += 64; // Stima generica
            }

            return size;
        }

        public long getCurrentMemoryUsage() {
            return currentMemoryBytes;
        }

        public int size() {
            return map.size();
        }
    }
}

Monitoring e Profiling

Memory Usage Tracking

import java.lang.instrument.Instrumentation;

public class MemoryProfilingDemo {

    // Misuratore accurato di memory usage (richiede agent)
    static class ObjectSizeMeasurer {
        private static Instrumentation instrumentation;

        public static void premain(String args, Instrumentation inst) {
            instrumentation = inst;
        }

        public static long sizeOf(Object obj) {
            if (instrumentation == null) {
                throw new IllegalStateException("Instrumentation not available");
            }
            return instrumentation.getObjectSize(obj);
        }

        public static long deepSizeOf(Object obj) {
            // Implementazione per calcolo ricorsivo della dimensione
            return calculateDeepSize(obj, new IdentityHashMap<>());
        }

        private static long calculateDeepSize(Object obj, IdentityHashMap<Object, Boolean> visited) {
            if (obj == null || visited.containsKey(obj)) {
                return 0;
            }

            visited.put(obj, Boolean.TRUE);
            long size = sizeOf(obj);

            Class<?> clazz = obj.getClass();

            if (clazz.isArray()) {
                if (clazz.getComponentType().isPrimitive()) {
                    return size; // Array di primitive
                } else {
                    // Array di oggetti
                    Object[] array = (Object[]) obj;
                    for (Object element : array) {
                        size += calculateDeepSize(element, visited);
                    }
                }
            } else {
                // Oggetto normale - rifletti sui campi
                while (clazz != null) {
                    for (Field field : clazz.getDeclaredFields()) {
                        if (!field.getType().isPrimitive() &&
                            !Modifier.isStatic(field.getModifiers())) {
                            field.setAccessible(true);
                            try {
                                Object fieldValue = field.get(obj);
                                size += calculateDeepSize(fieldValue, visited);
                            } catch (IllegalAccessException e) {
                                // Ignora campi non accessibili
                            }
                        }
                    }
                    clazz = clazz.getSuperclass();
                }
            }

            return size;
        }
    }

    // Memory benchmark framework
    static class MemoryBenchmark {
        public static void benchmarkDataStructures() {
            Runtime runtime = Runtime.getRuntime();

            // Test ArrayList vs IntArrayList
            runtime.gc();
            long startMemory = runtime.totalMemory() - runtime.freeMemory();

            List<Integer> arrayList = new ArrayList<>();
            for (int i = 0; i < 100000; i++) {
                arrayList.add(i);
            }

            runtime.gc();
            long arrayListMemory = runtime.totalMemory() - runtime.freeMemory() - startMemory;

            // Reset
            arrayList = null;
            runtime.gc();
            startMemory = runtime.totalMemory() - runtime.freeMemory();

            int[] intArray = new int[100000];
            for (int i = 0; i < 100000; i++) {
                intArray[i] = i;
            }

            runtime.gc();
            long intArrayMemory = runtime.totalMemory() - runtime.freeMemory() - startMemory;

            System.out.printf("ArrayList memory: %d KB%n", arrayListMemory / 1024);
            System.out.printf("int[] memory: %d KB%n", intArrayMemory / 1024);
            System.out.printf("Memory savings: %.1f%%%n",
                (1.0 - (double) intArrayMemory / arrayListMemory) * 100);
        }
    }
}

Best Practices

Profile Before Optimizing: Usa memory profiler per identificare hotspot reali.

Choose Right Data Structures: Primitive collections, packed structures quando appropriato.

Minimize Object Creation: Object pooling, immutable objects, factory methods.

Optimize String Usage: Interning, StringBuilder pooling, evita concatenazioni in loop.

Use Off-Heap Storage: Per dataset grandi che non richiedono elaborazione frequente.

Monitor Memory Patterns: Tracking allocation rate, GC frequency, heap utilization.

Consider Memory vs CPU Trade-offs: Compression può ridurre memoria a costo di CPU.

Conclusione

L’ottimizzazione della memoria in Java è un processo multifaceted che richiede comprensione profonda della JVM, delle strutture dati e dei pattern di accesso dell’applicazione. Le tecniche vanno da semplici scelte di data structure a approcci sofisticati come memory mapping e off-heap storage.

Il successo dell’ottimizzazione dipende dal bilanciamento tra memory footprint, performance e complessità del codice. Un approccio iterativo basato su measurement e profiling è essenziale per identificare i reali bottleneck e applicare le ottimizzazioni più efficaci.

Con l’evoluzione della JVM e l’introduzione di nuovi garbage collector, molte ottimizzazioni tradizionali diventano meno critiche, ma la comprensione dei principi fondamentali rimane essenziale per sviluppare applicazioni Java efficienti e scalabili.