【JUC基础】11. 并发下的集合类

简介: 我们直到ArrayList,HashMap等是线程不安全的容器。但是我们通常会频繁的在JUC中使用集合类,那么应该如何确保线程安全?

 1、前言

我们直到ArrayList,HashMap等是线程不安全的容器。但是我们通常会频繁的在JUC中使用集合类,那么应该如何确保线程安全?

2、并发下的ArrayList

2.1、传统方式

如果在JUC中直接使用ArrayList,可能会引发一系列问题。先来看一段代码:

public class ArrayListTest {
    // 创建一个集合类
    static List<Integer> list = new ArrayList<>(10);
    public static void main(String[] args) throws InterruptedException {
        // 这里执行10次,对比10次结果
        for (int i = 0; i < 10; i++) {
            // 每次执行前将list清空
            list.clear();
            // 创建两个线程,分别往list里面存放数据,每个线程存放10000个
            Thread thread1 = new Thread(new MyThread());
            Thread thread2 = new Thread(new MyThread());
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            // 当两个线程执行完毕后,期望值应该是list会扩容到20000个,并且打印list.sizes()=20000
            System.out.println("最终集合数量:" + list.size());
        }
    }
    // 操作集合list线程
    static class MyThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                list.add(i);
            }
        }
    }
}

image.gif

执行结果:

image.png

我们看到执行了10次,居然会出现3种不同的结果。

2.1.1、程序正常运行

从上述的运行结果可以看出,运行10次,有概率出现程序正常运行,也得到了期望的20000这个数值。这说明在JUC中使用ArrayList集合,有概率成功,并不一定每次都会出现问题。

2.1.2、程序异常

可以看到上面其中一次运行结果出现了报错,抛出了ArrayIndexOutOfBoundsException异常。这是因为ArrayList我们设置初始容量为10,在多线程操作中要进行扩容。而在扩容过程中,内部的一致性被破坏,由于没有锁机制,另外一个线程访问到了不一致的内部状态,导致数组越界。

2.1.3、运行期望值不符

相比上面程序异常,程序异常会显式抛出异常信息,还相对容易排查。而这个问题较为隐蔽,从执行结果来看,大部分都是这个问题。也就是运行结果并不是我们所期望的结果。JUC学到这里,应该多少都直到这个就是典型的线程不安全导致的结果。由于多线程访问冲突,使得list容器大小的变量被多线程不正常访问,两个线程对list中的同一个位置进行赋值导致的。

2.2、加锁

上面说到list没有锁机制,出现了多线程问题。那么要解决此类问题,肯定是直接加锁, 我们顺便把集合数量改大点。改造后代码:

public class ArrayListTest {
    // 创建一个集合类
    static List<Integer> list = new ArrayList<>(10);
    public static void main(String[] args) throws InterruptedException {
        // 这里执行10次,对比10次结果
        for (int i = 0; i < 10; i++) {
            // 每次执行前将list清空
            list.clear();
            // 创建两个线程,分别往list里面存放数据,每个线程存放1000000个
            Thread thread1 = new Thread(new MyThread());
            Thread thread2 = new Thread(new MyThread());
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            // 当两个线程执行完毕后,期望值应该是list会扩容到2000000个,并且打印list.sizes()=2000000
            System.out.println("最终集合数量:" + list.size());
        }
    }
    // 操作集合list线程
    static class MyThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 1000000; i++) {
                synchronized (list) {
                    list.add(i);
                }
            }
        }
    }
}

image.gif

运行结果:

image.png

说明线程安全问题被解决。

2.3、synchronizedList

相比上面直接加synchronized方法的解决方式,JDK提供了一种自带synchronized的集合,来保证线程安全。如vector也是如此。

改造代码:

public class ArrayListTest {
    // 创建一个集合类,Collections.synchronizedList来保证线程安全
    static List<Integer> list = Collections.synchronizedList(new ArrayList<>(10));
    public static void main(String[] args) throws InterruptedException {
        // 这里执行10次,对比10次结果
        for (int i = 0; i < 10; i++) {
            // 每次执行前将list清空
            list.clear();
            // 创建两个线程,分别往list里面存放数据,每个线程存放10000个
            Thread thread1 = new Thread(new MyThread());
            Thread thread2 = new Thread(new MyThread());
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            // 当两个线程执行完毕后,期望值应该是list会扩容到20000个,并且打印list.sizes()=20000
            System.out.println("最终集合数量:" + list.size());
        }
    }
    // 操作集合list线程
    static class MyThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 1000000; i++) {
                list.add(i);
            }
        }
    }
}

image.gif

同样执行结果:

image.png

2.4、CopyOnWriteArrayList

JUC也给我们提供了一种线程安全的变体ArrayList。根据名字就可以直到他是采用复制“快照”的方式,性能上是会有一定开销的。这里在实验过程中,明显感觉得到结果的速度变慢了。

image.png

改造后代码:

public class ArrayListTest {
    // 创建一个集合类,CopyOnWriteArrayList,写入时复制。
    // 当多个线程调用的时候,对list进行写入操作时,将数据拷贝避免由于多线程同时操作而被覆盖。可以简单理解成读写分离操作。
    // 这个类的操作使用的是lock锁,相比上述的两种synchronized来实现同步,性能更高
    static List<Integer> list = new CopyOnWriteArrayList<>();
    public static void main(String[] args) throws InterruptedException {
        // 这里执行10次,对比10次结果
        for (int i = 0; i < 10; i++) {
            // 每次执行前将list清空
            list.clear();
            // 创建两个线程,分别往list里面存放数据,每个线程存放10000个
            Thread thread1 = new Thread(new MyThread());
            Thread thread2 = new Thread(new MyThread());
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            // 当两个线程执行完毕后,期望值应该是list会扩容到20000个,并且打印list.sizes()=20000
            System.out.println("最终集合数量:" + list.size());
        }
    }
    // 操作集合list线程
    static class MyThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                list.add(i);
            }
        }
    }
}

image.gif

运行结果:

image.png

那么他是如何保证线程安全的呢?我们查看他的源码发现:

image.png

在他的setArra方法中,对array加了transient和volatile修饰,从而保证了线程安全。

transient:被transient修饰的属性,是不会被序列化的。后面有机会单独详细讲

volatile:防止指令重排,以及保证可见性。他是java中一种轻量的同步机制,相比synchronized来说,volatile更轻量级。后面单独会讲

3、并发下的HashSet

HashSet和ArrayList存在同样的问题。

public class HashSetTest {
    static Set<Integer> hashSet = new HashSet<>();
    public static void main(String[] args) throws InterruptedException {
        // 这里执行10次,对比10次结果
        for (int i = 0; i < 10; i++) {
            // 每次执行前将list清空
            hashSet.clear();
            // 创建两个线程,分别往list里面存放数据,每个线程存放10000个
            Thread thread1 = new Thread(new MyThread());
            Thread thread2 = new Thread(new MyThread());
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            // 当两个线程执行完毕后,期望值应该是list会扩容到20000个,并且打印list.sizes()=20000
            System.out.println("最终集合数量:" + hashSet.size());
        }
    }
    // 操作集合list线程
    static class MyThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                hashSet.add(i);
            }
        }
    }
}

image.gif

执行结果:

image.png

与ArrayList类似,当然也存在加锁的方式。同样采用JDK提供的方式:

Collections.synchronizedSet(new HashSet<>());

image.gif

3.1、CopyOnWriteArraySet

同样JUC也提供了类似CopyOnWriteArrayList的方式。

image.png

改造后代码:

public class HashSetTest {
    static Set<Integer> hashSet = new CopyOnWriteArraySet<>();
    public static void main(String[] args) throws InterruptedException {
        // 这里执行10次,对比10次结果
        for (int i = 0; i < 10; i++) {
            // 每次执行前将list清空
            hashSet.clear();
            // 创建两个线程,分别往list里面存放数据,每个线程存放10000个
            Thread thread1 = new Thread(new MyThread());
            Thread thread2 = new Thread(new MyThread());
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            // 当两个线程执行完毕后,期望值应该是list会扩容到20000个,并且打印list.sizes()=20000
            System.out.println("最终集合数量:" + hashSet.size());
        }
    }
    // 操作集合list线程
    static class MyThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                hashSet.add(i);
            }
        }
    }
}

image.gif

运行结果:

image.png

3.2、HashSet底层是什么?

细心的网友有没有发现,这里的运行结果也不是我们期望的20000。而是10000。那么是不是说明这里其实并不能保证线程安全?JDK出bug了?

这里就涉及到HashSet的底层存储结构了。我们跟进去看下HashSet源码:

image.png

我们可以看到HashSet的底层结构其实是个HashMap,而HashSet存储的是使用了HashMap的key。这就保证了HashSet的存储是不能重复的。

hashSet的add方法使用的就是HashMap的put方法:

image.png

而我们上面两个线程都同时从0开始存储,因而被去重导致期望结果是10000。而CopyOnWriteArraySet虽然实现存储结构是CopyOnWriteArrayList,但他保留了Hashset的去重结构,在add的时候使用了AddIfAbsent,因而输出的结果值为10000。

image.png

要验证这个结果其实也很简单,我们把hashSet.add()中的值,改为不重复的,比如使用雪花id来填充:

public class HashSetTest {
    static Set<String> hashSet = new CopyOnWriteArraySet<>();
    public static void main(String[] args) throws InterruptedException {
        // 这里执行10次,对比10次结果
        for (int i = 0; i < 10; i++) {
            // 每次执行前将list清空
            hashSet.clear();
            // 创建两个线程,分别往list里面存放数据,每个线程存放10000个
            Thread thread1 = new Thread(new MyThread());
            Thread thread2 = new Thread(new MyThread());
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            // 当两个线程执行完毕后,期望值应该是list会扩容到20000个,并且打印list.sizes()=20000
            System.out.println("最终集合数量:" + hashSet.size());
        }
    }
    // 操作集合list线程
    static class MyThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                hashSet.add(IdUtil.getSnowflakeNextIdStr());
            }
        }
    }
}

image.gif

那么结果就是我们想要的20000了:

image.png

4、并发下的HashMap

4.1、传统方式

public class HashMapTest {
    static Map<String, Object> hashMap = new HashMap<>();
    public static void main(String[] args) throws InterruptedException {
        // 这里执行10次,对比10次结果
        for (int i = 0; i < 10; i++) {
            // 每次执行前将list清空
            hashMap.clear();
            // 创建两个线程,分别往list里面存放数据,每个线程存放10000个
            Thread thread1 = new Thread(new MyThread());
            Thread thread2 = new Thread(new MyThread());
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            // 当两个线程执行完毕后,期望值应该是list会扩容到20000个,并且打印list.sizes()=20000
            System.out.println("最终集合数量:" + hashMap.size());
        }
    }
    // 操作集合list线程
    static class MyThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                hashMap.put(IdUtil.getSnowflakeNextIdStr(), i);
            }
        }
    }
}

image.gif

运行结果:

image.png

同样也存在线程安全问题。与ArrayList类似,当然也存在加锁的方式。同样采用JDK提供的方式:

Collections.synchronizedMap(new HashMap<>());

image.gif

4.2、ConcurrentHashMap

与CopyOnWriteArrayList或者set类似,JUC也提供了线程安全的Map集合。只是换个了名字:ConcurrentHashMap。

image.png

改造后代码:

public class HashMapTest {
    static Map<String, Object> hashMap = new ConcurrentHashMap<>();
    public static void main(String[] args) throws InterruptedException {
        // 这里执行10次,对比10次结果
        for (int i = 0; i < 10; i++) {
            // 每次执行前将list清空
            hashMap.clear();
            // 创建两个线程,分别往list里面存放数据,每个线程存放10000个
            Thread thread1 = new Thread(new MyThread());
            Thread thread2 = new Thread(new MyThread());
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            // 当两个线程执行完毕后,期望值应该是list会扩容到20000个,并且打印list.sizes()=20000
            System.out.println("最终集合数量:" + hashMap.size());
        }
    }
    // 操作集合list线程
    static class MyThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                hashMap.put(IdUtil.getSnowflakeNextIdStr(), i);
            }
        }
    }
}

image.gif

运行结果:

image.png

4.3、ConcurrentHashMap底层结构

那么JUC为什么不叫CopyOnWriteHashMap,而改名叫ConcurrentHashMap呢?因为他们两者的实现方式完全不一样。 前面讲到CopyOnWriteArrayList是采用复制快照的方式,实现类似读写分离的方式来确保数值不会被覆盖。

而ConcurrentHashMap却采用了分段锁的机制来确保线程安全。具体的后面专门来讲。这里只需要记住ConcurrentHashMap是可以保证线程安全即可。

可以初步看到源码中采用了分段,并添加了synchronized同步块代码,来确保高性能下的线程安全。

image.png

5、小结

学到这里,我们发现java下的集合类大部分都不是线程安全的。而为了确保线程安全,我们可以采取多种措施,包括JDK也提供了多种方式来确保集合在多线程中的线程安全问题。而很多时候,因为集合线程不安全导致的问题是很隐蔽的,如上述示例代码所示,并不会每次都显式的抛出异常信息,只是会让你每次的结果不一致,而每次运行结果未必都会复现。所以针对此类问题,需要谨慎对待。

相关文章
|
6天前
|
存储 安全 Java
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)(上)
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)
38 0
|
6天前
|
存储 安全 Java
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)(下)
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)(下)
45 0
|
6天前
|
存储 安全 算法
java多线程之并发容器集合
java多线程之并发容器集合
|
6天前
|
存储 缓存 安全
Java 并发编程中的高效同步机制:深入理解 ConcurrentHashMap
在多线程环境下,高效的数据同步是确保程序正确性的关键。本文将深入分析 Java 中提供的一个高级并发工具——ConcurrentHashMap,探讨其设计原理、功能特性以及在实际开发中的应用。通过对其内部结构和工作机制的剖析,读者将了解为何 ConcurrentHashMap 能够在保证线程安全的同时,提供比传统同步手段更高的吞吐率和更佳的性能表现。
|
6天前
|
存储 安全 Java
Java并发基础:CopyOnWriteArrayList全面解析
CopyOnWriteArrayList类的最大优点在于读取时无需加锁,非常适合读多写少的并发场景,由于其写操作通过复制底层数据来实现,从而保证了读取数据的一致性和高效性,此外,它简单易用,是快速实现线程安全列表的不错选择,CopyOnWriteArrayList在读操作占主导的场景下,能够提供出色的性能和稳定性。
Java并发基础:CopyOnWriteArrayList全面解析
|
6天前
|
安全 Java 编译器
Java并发编程学习6-同步容器类和并发容器
【1月更文挑战第6天】本篇介绍同步容器类和并发容器的相关内容(Vector、ConcurrentHashMap、CopyOnWriteArrayList)
49 3
Java并发编程学习6-同步容器类和并发容器
|
10月前
|
分布式计算 测试技术 开发者
JUC基础(五)—— 并发工具类
JUC基础(五)—— 并发工具类
85 0
|
10月前
|
安全 Java 数据安全/隐私保护
JUC基础(四)—— 并发集合
JUC基础(四)—— 并发集合
91 0
|
11月前
|
存储 安全 算法
【Java并发编程 十一】JUC并发包下并发容器类(下)
【Java并发编程 十一】JUC并发包下并发容器类(下)
79 0
|
11月前
|
存储 安全 算法
【Java并发编程 十一】JUC并发包下并发容器类(上)
【Java并发编程 十一】JUC并发包下并发容器类(上)
53 0