【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,可能会引发一系列问题。先来看一段代码:


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);
            }
        }
    }
}

执行结果:

我们看到执行了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);
                }
            }
        }
    }
}

运行结果:

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

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);
            }
        }
    }
}

同样执行结果:

2.4、CopyOnWriteArrayList

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

改造后代码:


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);
            }
        }
    }
}

运行结果:

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

在他的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);
            }
        }
    }
}

执行结果:

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

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

3.1、CopyOnWriteArraySet

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

改造后代码:

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);
            }
        }
    }

}

运行结果:

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来填充:

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());
            }
        }
    }
}

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

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);
            }
        }
    }

}

运行结果:

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

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

4.2、ConcurrentHashMap

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

改造后代码:

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);
            }
        }
    }

}

运行结果:

4.3、ConcurrentHashMap底层结构

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

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

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

5、小结

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

猜你喜欢

转载自blog.csdn.net/p793049488/article/details/130911768