前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一文说透如何同步的方式操作HashMap

一文说透如何同步的方式操作HashMap

作者头像
用户7634691
发布2020-12-17 11:19:50
9820
发布2020-12-17 11:19:50
举报

写在前面

很多人都知道HashMap是非线程安全的。比如下面这段代码,多运行几次,基本每次会抛出异常:

final Map<Integer, String> map = new HashMap<>();
        long count = 0;

        final Integer targetKey = 0b1111_1111_1111_1111; // 65 535
        final String targetValue = "v";

        map.put(targetKey, targetValue);

        //range的范围是0~65534
        new Thread(() -> {
            IntStream.range(0, targetKey).forEach(key -> map.put(key, "someValue"));
        }).start();


        while (true) {
            if (!targetValue.equals(map.get(targetKey))) {
                System.out.println("跑了" + count + "次,抛出异常了");
                throw new RuntimeException("HashMap is not thread safe.");
            }
            count++;

        }

抛出异常就是HashMap的证明。上面的示例中,一个线程不断的put,另一个线程检查一开始设置的某个key的value是否有变化。正常情况下当然不会有变化。

但是因为HashMap是非线程安全的,在put过程中会触发resize(扩容),而这个动作在多线程环境下容易形成死循环或者数据错乱。

使用 Collections.synchronizedMap同步

这是java.util.Collections提供的一个静态方法,用这个方法包装下HashMap,它就变成线程安全的了。示例:

final Map<Integer, String> map = new HashMap<>();
        long count = 0;

        // Synchronized HashMap
        Map<Integer, String> synchronizedMap
                = Collections.synchronizedMap(map);


        final Integer targetKey = 0b1111_1111_1111_1111; // 65 535
        final String targetValue = "v";

        synchronizedMap.put(targetKey, targetValue);

        //range的范围是0~65534
        new Thread(() -> {
            IntStream.range(0, targetKey).forEach(key -> synchronizedMap.put(key, "someValue"));
        }).start();


        while (true) {
            if (!targetValue.equals(synchronizedMap.get(targetKey))) {
                System.out.println("跑了" + count + "次,抛出异常了");
                throw new RuntimeException("HashMap is not thread safe.");
            }
            count++;

        }

这段代码无论你运行多少次都不会抛出异常了。

synchronizedMap实现线程安全的原理也很简单,它首先基于当前的map对象生成一个新的map类型synchronizedMap, 这是Collections类里面的一个内部类。

public static <K,V> Map<K,V> synchronizedMap(Map<K,V,> m) {
        return new SynchronizedMap<>(m);
    }

进入源码可以看到它的所有操作都用了synchronized加了一个对象锁,

public int size() {
            synchronized (mutex) {return m.size();}
        }
        public boolean isEmpty() {
            synchronized (mutex) {return m.isEmpty();}
        }
        public boolean containsKey(Object key) {
            synchronized (mutex) {return m.containsKey(key);}
        }
        public boolean containsValue(Object value) {
            synchronized (mutex) {return m.containsValue(value);}
        }
        public V get(Object key) {
            synchronized (mutex) {return m.get(key);}
        }

        public V put(K key, V value) {
            synchronized (mutex) {return m.put(key, value);}
        }
        public V remove(Object key) {
            synchronized (mutex) {return m.remove(key);}
        }
        public void putAll(Map<? extends K, ? extends V> map) {
            synchronized (mutex) {m.putAll(map);}
        }
        public void clear() {
            synchronized (mutex) {m.clear();}
        }

使用ConcurrentHashMap同步

还是上面那个示例,改成ConcurrentMap的实现:

final Map<Integer, String> synchronizedMap = new ConcurrentHashMap<>();
        long count = 0;

        final Integer targetKey = 0b1111_1111_1111_1111; // 65 535
        final String targetValue = "v";

        synchronizedMap.put(targetKey, targetValue);

        //range的范围是0~65534
        new Thread(() -> {
            IntStream.range(0, targetKey).forEach(key -> synchronizedMap.put(key, "someValue"));
        }).start();


        while (true) {
            if (!targetValue.equals(synchronizedMap.get(targetKey))) {
                System.out.println("跑了" + count + "次,抛出异常了");
                throw new RuntimeException("HashMap is not thread safe.");
            }
            count++;

        }

运行不抛出异常说明同步成功了。

在java7中,ConcurrentHashMap 是一个segment数组,segment通过继承 ReentrantLock来进行加锁,锁的颗粒度比较细,相当于每次锁住的是一个segment。这样性能更高。

java8的方式有些区别,是通过CAS实现的,这里不展开。

使用迭代器访问的情况需要关注下

synchronizedMap虽然是线程安全的map,但是它返回的迭代器(iterator)依然是HashMap的迭代器,而HashMap是fail-fast,并发修改的时候会报错。

看下面这个示例:

try {
            Map<String, String> hashMap = new HashMap<>();
            hashMap.put("1", "Hello");
            hashMap.put("2", "World");

            Map<String, String> synchronizedMap = Collections.synchronizedMap(hashMap);
            Iterator<String> it = synchronizedMap.keySet().iterator();
            while(it.hasNext()) {
                Object ele = it.next();
                System.out.println(synchronizedMap);
                if (ele.equals("1")) {
                    synchronizedMap.remove(ele);    //出错 修改了映射结构 影响了迭代器遍历
                }
            }

        } catch (Exception e) {
            // TODO: handle exception
            e.printStackTrace();
        }

会抛出ConcurrentModificationException异常。

如果使用ConcurrentHashMap则不同,因为它本身是对Map底层做了重新实现,针对并发访问进行了优化,可以认为在一定程度是“并发迭代器”。

至于实现的原理我这里就不多说了,推荐一篇文章:

http://ifeve.com/java-concurrent-hashmap-1/[1]

同步的HashMap也不是银弹

有些人会误以为使用了同步的HashMap就可以“为所欲为”了,其实在某些场景下ConcurrentHashMap也存在线程安全问题。下面是个示例:

public class TestHashMap {
    public static final int N = 5;
    public static final String KEY = "count";
    final Map<String, Integer> mapTest = new ConcurrentHashMap<String, Integer>();

    //初始化
    public TestHashMap() {
        mapTest.put(KEY, 0);
    }


    public static void main(String[] args) throws Exception{

        TestHashMap testHashMap = new TestHashMap();

        Thread thread1 = new Thread() {
            @Override
            public void run() {
                for(int j=0; j<100; j++){
                    testHashMap.add();
                }
            }
        };

        Thread thread2 = new Thread() {
            @Override
            public  void run() {
                for(int j=0; j<100; j++){
                    testHashMap.add();
                }
            }
        };


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

        //打印结果跟你想的是不是一样呢?
        testHashMap.printResult();
    }

    public void add() {
        mapTest.put(KEY, mapTest.get(KEY)+1);
    }

    public void printResult() {
        System.out.println(mapTest.get(KEY));

    }
}

这个其实想想是很容易理解的,这是一个先读后写测场景,写的数据依赖前面读的结果。所以我们应该要保证读-写这整个操作的原子性,而ConcurrentHashMap本身只是保证Map内部数据结构操作的原子性。

这个跟我们在数据库里操作一行数据很像,比如A、B两个线程操作1号账户的钱包(表里的一行记录),余额为1000元。A线程为该账户增加100元,B线程同时为该账户扣除50元,A先提交,B后提交。最后实际账户余额为1000-50=950元,但本该为1000+100-50=1050。

虽然数据库本身有行锁,但是我们操作的时候还是需要给上面的流程整体加锁(悲观锁,乐观锁都可以)

说了这么多,上面这个问题的解决方案也呼之欲出了,只需要在add方法加锁即可。

public synchronized void add() {
        mapTest.put(KEY, mapTest.get(KEY)+1);
    }

总结下synchronizedMap 和 ConcurrentHashMap的区别

首先,从上面分析的同步原理看,synchronizedMap加锁是基于操作的,简单粗暴。而ConcurrentHashMap是分段加锁,锁的颗粒度更细,性能自然更高。高并发的场景下还是建议使用后者。

还有一个区别是,ConcurrentHashMap永远不会抛出ConcurrentModificationException异常。而synchronizedMap在迭代遍历时,如果某些元素被删除了,会触发fail-fast机制抛出ConcurrentModificationException异常。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-12-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 犀牛的技术笔记 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 写在前面
  • 使用 Collections.synchronizedMap同步
  • 使用ConcurrentHashMap同步
  • 使用迭代器访问的情况需要关注下
  • 同步的HashMap也不是银弹
  • 总结下synchronizedMap 和 ConcurrentHashMap的区别
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档