È uscito il Corso SQL Completo

Ottimizzazione della Memoria in Java

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.