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


    目录

     1、前言

    2、并发下的ArrayList

    2.1、传统方式

    2.1.1、程序正常运行

    2.1.2、程序异常

    2.1.3、运行期望值不符

    2.2、加锁

    2.3、synchronizedList

    2.4、CopyOnWriteArrayList

    3、并发下的HashSet

    3.1、CopyOnWriteArraySet

    3.2、HashSet底层是什么?

    4、并发下的HashMap

    4.1、传统方式

    4.2、ConcurrentHashMap

    4.3、ConcurrentHashMap底层结构

    5、小结


     1、前言

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

    2、并发下的ArrayList

    2.1、传统方式

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

    1. public class ArrayListTest {
    2. // 创建一个集合类
    3. static List<Integer> list = new ArrayList<>(10);
    4. public static void main(String[] args) throws InterruptedException {
    5. // 这里执行10次,对比10次结果
    6. for (int i = 0; i < 10; i++) {
    7. // 每次执行前将list清空
    8. list.clear();
    9. // 创建两个线程,分别往list里面存放数据,每个线程存放10000
    10. Thread thread1 = new Thread(new MyThread());
    11. Thread thread2 = new Thread(new MyThread());
    12. thread1.start();
    13. thread2.start();
    14. thread1.join();
    15. thread2.join();
    16. // 当两个线程执行完毕后,期望值应该是list会扩容到20000个,并且打印list.sizes()=20000
    17. System.out.println("最终集合数量:" + list.size());
    18. }
    19. }
    20. // 操作集合list线程
    21. static class MyThread implements Runnable {
    22. @Override
    23. public void run() {
    24. for (int i = 0; i < 10000; i++) {
    25. list.add(i);
    26. }
    27. }
    28. }
    29. }

    执行结果:

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

    2.1.1、程序正常运行

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

    2.1.2、程序异常

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

    2.1.3、运行期望值不符

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

    2.2、加锁

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

    1. public class ArrayListTest {
    2. // 创建一个集合类
    3. static List<Integer> list = new ArrayList<>(10);
    4. public static void main(String[] args) throws InterruptedException {
    5. // 这里执行10次,对比10次结果
    6. for (int i = 0; i < 10; i++) {
    7. // 每次执行前将list清空
    8. list.clear();
    9. // 创建两个线程,分别往list里面存放数据,每个线程存放1000000
    10. Thread thread1 = new Thread(new MyThread());
    11. Thread thread2 = new Thread(new MyThread());
    12. thread1.start();
    13. thread2.start();
    14. thread1.join();
    15. thread2.join();
    16. // 当两个线程执行完毕后,期望值应该是list会扩容到2000000个,并且打印list.sizes()=2000000
    17. System.out.println("最终集合数量:" + list.size());
    18. }
    19. }
    20. // 操作集合list线程
    21. static class MyThread implements Runnable {
    22. @Override
    23. public void run() {
    24. for (int i = 0; i < 1000000; i++) {
    25. synchronized (list) {
    26. list.add(i);
    27. }
    28. }
    29. }
    30. }
    31. }

    运行结果:

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

    2.3、synchronizedList

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

    改造代码:

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

    同样执行结果:

    2.4、CopyOnWriteArrayList

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

    改造后代码:

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

    运行结果:

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

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

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

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

    3、并发下的HashSet

    HashSet和ArrayList存在同样的问题。

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

    执行结果:

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

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

    3.1、CopyOnWriteArraySet

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

    改造后代码:

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

    运行结果:

    3.2、HashSet底层是什么?

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

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

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

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

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

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

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

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

    4、并发下的HashMap

    4.1、传统方式

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

    运行结果:

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

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

    4.2、ConcurrentHashMap

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

    改造后代码:

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

    运行结果:

    4.3、ConcurrentHashMap底层结构

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

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

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

    5、小结

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

  • 相关阅读:
    【计算机网络】HTTPS的基础知识
    前端异常监控系统
    MySQL数据类型介绍及使用场景
    31一维信号滤波(限幅滤波、中值滤波、均值滤波、递推平均滤波),MATLAB程序已调通,可直接运行。
    redis搭建主从、redis搭建集群、redis中StrictRedis()、RedisCluster()方法与python交互
    双馈风力发电机-900V直流混合储能并网系统Simulink仿真
    清华训练营悟道篇之操作系统的调用接口
    Exoplayer简介
    ES6之数值的扩展
    MySQL总结(DQL)
  • 原文地址:https://blog.csdn.net/p793049488/article/details/130911768