MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java HashMap 的并发访问控制

2021-02-085.2k 阅读

1. Java HashMap 基础回顾

在深入探讨 Java HashMap 的并发访问控制之前,让我们先简要回顾一下 HashMap 的基本原理。HashMap 是 Java 集合框架中的一部分,它实现了 Map 接口,用于存储键值对(key - value pairs)。它基于哈希表的原理工作,通过对键(key)进行哈希运算,将键值对存储到不同的桶(bucket)中,以实现快速的查找、插入和删除操作。

以下是一个简单的 HashMap 使用示例:

import java.util.HashMap;
import java.util.Map;

public class HashMapExample {
    public static void main(String[] args) {
        Map<String, Integer> hashMap = new HashMap<>();
        hashMap.put("apple", 10);
        hashMap.put("banana", 20);
        hashMap.put("cherry", 30);

        Integer value = hashMap.get("banana");
        System.out.println("Value for banana: " + value);
    }
}

在上述代码中,我们创建了一个 HashMap 并向其中插入了三个键值对,然后通过键“banana”获取对应的值。

2. 并发访问问题的产生

当多个线程同时访问和修改 HashMap 时,就可能会出现并发访问问题。HashMap 本身并不是线程安全的,这意味着在多线程环境下,它的行为可能是不确定的,甚至会导致程序出现严重的错误。

2.1 数据竞争(Data Race)

数据竞争是指多个线程同时访问和修改共享数据,并且至少有一个线程是写操作,而没有适当的同步机制。在 HashMap 中,如果多个线程同时进行 put 操作,就可能出现数据竞争。

考虑以下示例代码:

import java.util.HashMap;
import java.util.Map;

public class HashMapDataRaceExample {
    private static Map<String, Integer> hashMap = new HashMap<>();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                hashMap.put("key" + i, i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 1000; i < 2000; i++) {
                hashMap.put("key" + i, i);
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Size of HashMap: " + hashMap.size());
    }
}

在这个例子中,两个线程同时向同一个 HashMap 中插入数据。由于没有同步机制,可能会导致某些数据丢失或者插入的数据不一致。

2.2 并发修改异常(ConcurrentModificationException)

当一个线程在遍历 HashMap 的同时,另一个线程对其进行了修改操作(如插入或删除),就可能抛出 ConcurrentModificationException 异常。

以下是一个简单的示例:

import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

public class ConcurrentModificationExample {
    private static Map<String, Integer> hashMap = new HashMap<>();

    public static void main(String[] args) {
        hashMap.put("apple", 10);
        hashMap.put("banana", 20);

        Thread thread1 = new Thread(() -> {
            Iterator<Map.Entry<String, Integer>> iterator = hashMap.entrySet().iterator();
            while (iterator.hasNext()) {
                Map.Entry<String, Integer> entry = iterator.next();
                System.out.println(entry.getKey() + ": " + entry.getValue());
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                Thread.sleep(200);
                hashMap.put("cherry", 30);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个例子中,线程 1 正在遍历 HashMap,而线程 2 在稍作延迟后向 HashMap 中插入新的键值对。这很可能会导致线程 1 抛出 ConcurrentModificationException 异常。

3. 并发访问控制的方法

为了在多线程环境中安全地使用 HashMap,我们需要采取一些并发访问控制的方法。

3.1 使用 Collections.synchronizedMap

Java 提供了 Collections.synchronizedMap 方法,它可以将任何 Map 实现类(包括 HashMap)转换为线程安全的 Map。这个方法返回一个同步的 Map 包装器,所有对 Map 的操作都被同步,以确保线程安全。

以下是使用示例:

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

public class SynchronizedMapExample {
    private static Map<String, Integer> synchronizedMap;

    public static void main(String[] args) {
        Map<String, Integer> hashMap = new HashMap<>();
        synchronizedMap = Collections.synchronizedMap(hashMap);

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                synchronizedMap.put("key" + i, i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 1000; i < 2000; i++) {
                synchronizedMap.put("key" + i, i);
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Size of synchronizedMap: " + synchronizedMap.size());
    }
}

在这个例子中,我们通过 Collections.synchronizedMap 将 HashMap 转换为线程安全的 Map。这样,多个线程同时对其进行操作时,就不会出现数据竞争的问题。

然而,虽然这种方法保证了线程安全,但它存在一些性能问题。由于所有的操作都被同步,当多个线程频繁地访问和修改 Map 时,会导致大量的线程等待,从而降低系统的并发性能。

3.2 使用 ConcurrentHashMap

ConcurrentHashMap 是 Java 提供的专门用于多线程环境的线程安全的哈希表。它与通过 Collections.synchronizedMap 包装的 HashMap 不同,它采用了更加细粒度的锁机制,允许多个线程同时访问不同的桶(bucket),从而提高了并发性能。

以下是一个简单的 ConcurrentHashMap 使用示例:

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public class ConcurrentHashMapExample {
    private static ConcurrentMap<String, Integer> concurrentHashMap = new ConcurrentHashMap<>();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                concurrentHashMap.put("key" + i, i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 1000; i < 2000; i++) {
                concurrentHashMap.put("key" + i, i);
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Size of ConcurrentHashMap: " + concurrentHashMap.size());
    }
}

在这个例子中,我们创建了一个 ConcurrentHashMap,并让两个线程同时向其中插入数据。由于 ConcurrentHashMap 内部的锁机制,多个线程可以高效地并发操作,而不会出现数据竞争和并发修改异常的问题。

ConcurrentHashMap 的实现原理主要基于分段锁(Segment)机制(在 JDK 1.8 之前),后来在 JDK 1.8 中进行了优化,采用了 CAS(Compare - and - Swap)操作和 synchronized 关键字相结合的方式。这种优化使得 ConcurrentHashMap 在高并发场景下具有更好的性能表现。

3.3 使用读写锁(ReadWriteLock)

如果我们的应用场景中读操作远远多于写操作,那么可以考虑使用读写锁(ReadWriteLock)来提高并发性能。Java 提供了 ReentrantReadWriteLock 类来实现读写锁。

以下是一个使用读写锁保护 HashMap 的示例:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private static Map<String, Integer> hashMap = new HashMap<>();
    private static ReadWriteLock readWriteLock = new ReentrantReadWriteLock();

    public static void main(String[] args) {
        Thread readThread1 = new Thread(() -> {
            readWriteLock.readLock().lock();
            try {
                for (int i = 0; i < 1000; i++) {
                    Integer value = hashMap.get("key" + i);
                    if (value != null) {
                        System.out.println("Read value for key" + i + ": " + value);
                    }
                }
            } finally {
                readWriteLock.readLock().unlock();
            }
        });

        Thread writeThread1 = new Thread(() -> {
            readWriteLock.writeLock().lock();
            try {
                for (int i = 0; i < 1000; i++) {
                    hashMap.put("key" + i, i);
                }
            } finally {
                readWriteLock.writeLock().unlock();
            }
        });

        readThread1.start();
        writeThread1.start();

        try {
            readThread1.join();
            writeThread1.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个例子中,读线程获取读锁,写线程获取写锁。读锁允许多个线程同时获取,而写锁则是独占的。这样,在高读低写的场景下,可以大大提高系统的并发性能。

4. 各种方法的性能比较

为了更直观地了解上述三种并发访问控制方法的性能差异,我们可以进行一些简单的性能测试。

4.1 测试环境

我们使用 Java 11,在一台具有 8 核处理器、16GB 内存的计算机上进行测试。测试场景包括大量的读操作和写操作,以模拟实际的多线程应用场景。

4.2 测试代码

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class PerformanceTest {
    private static final int THREADS = 10;
    private static final int OPERATIONS = 100000;

    public static void main(String[] args) throws InterruptedException {
        performanceTestSynchronizedMap();
        performanceTestConcurrentHashMap();
        performanceTestReadWriteLock();
    }

    private static void performanceTestSynchronizedMap() throws InterruptedException {
        Map<String, Integer> hashMap = new HashMap<>();
        Map<String, Integer> synchronizedMap = Collections.synchronizedMap(hashMap);

        ExecutorService executorService = Executors.newFixedThreadPool(THREADS);
        long startTime = System.currentTimeMillis();

        for (int i = 0; i < THREADS; i++) {
            executorService.submit(() -> {
                for (int j = 0; j < OPERATIONS; j++) {
                    synchronizedMap.put("key" + j, j);
                    synchronizedMap.get("key" + j);
                }
            });
        }

        executorService.shutdown();
        executorService.awaitTermination(1, TimeUnit.MINUTES);

        long endTime = System.currentTimeMillis();
        System.out.println("Time taken by SynchronizedMap: " + (endTime - startTime) + " ms");
    }

    private static void performanceTestConcurrentHashMap() throws InterruptedException {
        ConcurrentHashMap<String, Integer> concurrentHashMap = new ConcurrentHashMap<>();

        ExecutorService executorService = Executors.newFixedThreadPool(THREADS);
        long startTime = System.currentTimeMillis();

        for (int i = 0; i < THREADS; i++) {
            executorService.submit(() -> {
                for (int j = 0; j < OPERATIONS; j++) {
                    concurrentHashMap.put("key" + j, j);
                    concurrentHashMap.get("key" + j);
                }
            });
        }

        executorService.shutdown();
        executorService.awaitTermination(1, TimeUnit.MINUTES);

        long endTime = System.currentTimeMillis();
        System.out.println("Time taken by ConcurrentHashMap: " + (endTime - startTime) + " ms");
    }

    private static void performanceTestReadWriteLock() throws InterruptedException {
        Map<String, Integer> hashMap = new HashMap<>();
        ReadWriteLock readWriteLock = new ReentrantReadWriteLock();

        ExecutorService executorService = Executors.newFixedThreadPool(THREADS);
        long startTime = System.currentTimeMillis();

        for (int i = 0; i < THREADS; i++) {
            executorService.submit(() -> {
                for (int j = 0; j < OPERATIONS; j++) {
                    if (j % 2 == 0) {
                        readWriteLock.writeLock().lock();
                        try {
                            hashMap.put("key" + j, j);
                        } finally {
                            readWriteLock.writeLock().unlock();
                        }
                    } else {
                        readWriteLock.readLock().lock();
                        try {
                            hashMap.get("key" + j);
                        } finally {
                            readWriteLock.readLock().unlock();
                        }
                    }
                }
            });
        }

        executorService.shutdown();
        executorService.awaitTermination(1, TimeUnit.MINUTES);

        long endTime = System.currentTimeMillis();
        System.out.println("Time taken by ReadWriteLock: " + (endTime - startTime) + " ms");
    }
}

4.3 测试结果分析

通过多次运行上述测试代码,我们可以得到以下大致的性能比较结果:

  • SynchronizedMap:在高并发场景下,由于所有操作都被同步,其性能相对较差。随着线程数的增加,线程等待时间变长,整体操作时间明显增加。
  • ConcurrentHashMap:在高并发场景下,具有较好的性能表现。其细粒度的锁机制使得多个线程可以并发地访问不同的桶,减少了线程等待时间。
  • ReadWriteLock:在读写比例合适(读多写少)的场景下,性能表现良好。读操作可以并发执行,写操作则独占锁,从而在保证线程安全的同时提高了并发性能。但如果写操作过于频繁,性能会受到一定影响。

5. 选择合适的并发访问控制方法

在实际应用中,选择合适的并发访问控制方法至关重要。以下是一些选择建议:

  • 简单场景且并发度不高:如果应用场景比较简单,并发访问的频率不高,可以考虑使用 Collections.synchronizedMap。它的实现简单,能够满足基本的线程安全需求。
  • 高并发读写场景:对于高并发读写场景,ConcurrentHashMap 是一个更好的选择。它在保证线程安全的同时,具有较高的并发性能,能够适应大量线程同时访问和修改的情况。
  • 高读低写场景:如果应用场景中读操作远远多于写操作,使用读写锁(ReadWriteLock)可以显著提高并发性能。通过区分读锁和写锁,允许多个读操作并发执行,而写操作则独占锁,从而平衡了读写操作的性能。

6. 其他注意事项

在处理 HashMap 的并发访问时,除了选择合适的并发控制方法外,还有一些其他注意事项:

  • 避免不必要的同步:在使用同步机制时,要尽量缩小同步代码块的范围,避免不必要的同步操作,以提高系统的并发性能。
  • 合理设置初始容量:对于 HashMap 及其线程安全的变体,合理设置初始容量可以减少哈希冲突,提高性能。特别是在多线程环境下,不合适的初始容量可能会导致更多的竞争和性能问题。
  • 注意迭代器的使用:在多线程环境下使用迭代器时,要注意迭代器的线程安全性。对于线程不安全的迭代器,如 HashMap 的普通迭代器,在遍历过程中如果其他线程对 Map 进行修改,可能会抛出 ConcurrentModificationException 异常。因此,在多线程环境下,最好使用线程安全的迭代器,或者在遍历过程中对 Map 进行适当的同步保护。

通过对 Java HashMap 并发访问控制的深入探讨,我们了解了并发访问问题的产生原因、各种并发访问控制方法及其性能特点,以及在实际应用中选择合适方法的重要性和一些注意事项。希望这些知识能够帮助开发者在多线程环境中更安全、高效地使用 HashMap。