关于Java中CopyOnWriteArrayList的一些问题

点点圈 提交于 2021-01-23 10:55:44

1. 开始

假设有一个游戏服务,需要和客户端相互发送数据。

如果是你,你会怎么设计这个结构和逻辑。

我们还是先来看一个简化抽象版容易劝退的实际例子,当然这个例子不重要,完全可以跳过。

不过可以检查一下你对Java并发的熟悉程度,你能发现的问题也多,说明你对Java并发也了解。

因为下面的代码示例反应了很多朋友对多线程的理解的感觉。

这种感觉怎么说呢?不是不懂,各种JUC的工具感觉也熟悉,自己用着程序好像也没啥问题。

但是是总感觉多余多线程编程哪里有些点自己没有get到,但是,又不知道这些点具体是什么。

多线程编程最重要的是要理解:多线程带来的问题,这其中最重要的有2点:

  1. 数据一致性,这是一个永恒的主题
  2. 多个线程执行逻辑顺序不同带来的影响

很多朋友把使用多线程工具看得比理解多线程思想重要,这可能并不是一个好的方式。

会用和用好之间还是有很多差别,例如下面的例子,就体现了在多线程编程中这些懵懵懂懂的感觉。

import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;

public class SendDataHelper {

    CopyOnWriteArrayList<String> msgPool = new CopyOnWriteArrayList<>();

    static int USE_THREAD_MIN_SLEEP_TIME = 99;

    static int useThreadSendTime = USE_THREAD_MIN_SLEEP_TIME;

    static int maxCacheMsgSize = 200;

    private AtomicBoolean isSending = new AtomicBoolean(false);

    private AtomicBoolean isRunning = new AtomicBoolean(true);

    private Thread sendThread = new Thread(() -> {
        while (isRunning.get()) {
            try {
                if (msgPool.size() == 0) {
                    Thread.sleep(useThreadSendTime);
                } else {
                    doSendPool();
                }
            } catch (Throwable throwable) {
                throwable.printStackTrace();
            }
        }
    });

    private void sendSyn() {
        try {
            doSendPool();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        } finally {
            isSending.set(false);
        }
    }


    private void doSendPool() {
        while (msgPool.size() > 0) { //发送
            String message = msgPool.get(0);
            boolean suc = send(message);
            if (suc) {
                msgPool.remove(message);
            } else {
                break;
            }
        }
    }

    public void sendMsg(String message) {
        if (message == null) {
            return;
        }
        if (msgPool.size() >= maxCacheMsgSize) {
            // 关闭网络连接
            return;
        }

        msgPool.add(message);
        if (useThreadSendTime > USE_THREAD_MIN_SLEEP_TIME) {
            if (isRunning.get() && Thread.State.NEW == sendThread.getState()) {
                sendThread.start();
            }
        } else {
            if (isSending.compareAndSet(false, true)) {
                sendSyn();
            }
        }
    }

    private boolean send(String data){
        System.out.println("网络数据发送逻辑");
        return true;
    }
}

你能从中发现哪些问题?

2. CopyOnWriteArrayList

CopyOnWriteArrayList典型的借鉴了操作系统的CopyOnWrite(COW)技术思想。

操作系统的数据在用户空间和内核空间拷贝时,系统通常应用用写时复制(COW,copy on write)来减少系统开销。简单的来说就是多个用户程序共享数据块的时候,先不执行拷贝操作,当有修改操作的时候再执行拷贝操作。

Redis持久化RDB也使用了COW这个技术思想。

思考:COW的核心思想是什么?

典型的空间换时间。

通过拷贝方式获取数据副本,修改在副本上进行修改,以便于让多个线程可以并行的读,读操作永远不会阻塞。

写操作加锁,同一时间,只能有一个线程进行修改。

弄清楚了COW的原理,我们就很容易理解CopyOnWriteArrayList存在的问题

  1. CopyOnWriteArrayList只使用于读多写少场景,如黑名单、白名单、配置数据(目录、菜单、权限)、类型缓存(商品类型)
  2. CopyOnWriteArrayList保存的数据量不应该太大
  3. 有数据副本就有数据一致性问题,CopyOnWriteArrayList只保证数据最终一致性

简单说明:

  1. 如果写比较多(包括修改、删除),CopyOnWriteArrayList的锁争用并不会少,而且,还多了数据复制的成本
  2. CopyOnWriteArrayList如果存放数据量比较大,因为拷贝操作,可能造成频繁的GC、甚至OOM,如:数据500M,可能执行拷贝的时候就OOM了
  3. CopyOnWriteArrayList是不阻塞读操作的,就是正在被修改的数据是没有被读到的

上面的程序更像是一个生产者与消费者模式,读写一样多,在加上remove操作,写比读还多一倍,CopyOnWriteArrayList显然不适合。

上面的示例程序槽点还是比较多的,例如:

  1. 每个用户分配一次线程(当然,之所以没有爆出问题是因为,程序逻辑并没有走这个逻辑)
  2. 连接关闭之后线程没有释放
  3. 计算使用了安全队列,还自己管理和数据队列有关的状态

这些都反应了对多线程理解不够深刻,把自己的业务逻辑和多线程工具类的处理逻辑混在一起了。

3. 回到开始

这其实就是一个生产者与消费者模式的升级版本,只需要封装自己的逻辑就可以了。

当然,这也可能弄的很复杂,例如,弄成想Tomcat那样。

当然,如果想要简单的处理,那基本就是弄一个线程池。

我们的核心任务是把生产数据的逻辑和消费数据的逻辑封装成任务,扔给线程池就可以了。

至于,这个线程池多大、使用什么队列、使用什么拒绝策略,这些细节的东西应该根据实际业务需要,结合运行的统计数据调整。

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!