1. 开始
假设有一个游戏服务,需要和客户端相互发送数据。
如果是你,你会怎么设计这个结构和逻辑。
我们还是先来看一个简化抽象版容易劝退的实际例子,当然这个例子不重要,完全可以跳过。
不过可以检查一下你对Java并发的熟悉程度,你能发现的问题也多,说明你对Java并发也了解。
因为下面的代码示例反应了很多朋友对多线程的理解的感觉。
这种感觉怎么说呢?不是不懂,各种JUC的工具感觉也熟悉,自己用着程序好像也没啥问题。
但是是总感觉多余多线程编程哪里有些点自己没有get到,但是,又不知道这些点具体是什么。
多线程编程最重要的是要理解:多线程带来的问题,这其中最重要的有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存在的问题:
- CopyOnWriteArrayList只使用于读多写少场景,如黑名单、白名单、配置数据(目录、菜单、权限)、类型缓存(商品类型)
- CopyOnWriteArrayList保存的数据量不应该太大
- 有数据副本就有数据一致性问题,CopyOnWriteArrayList只保证数据最终一致性
简单说明:
- 如果写比较多(包括修改、删除),CopyOnWriteArrayList的锁争用并不会少,而且,还多了数据复制的成本
- CopyOnWriteArrayList如果存放数据量比较大,因为拷贝操作,可能造成频繁的GC、甚至OOM,如:数据500M,可能执行拷贝的时候就OOM了
- CopyOnWriteArrayList是不阻塞读操作的,就是正在被修改的数据是没有被读到的
上面的程序更像是一个生产者与消费者模式,读写一样多,在加上remove操作,写比读还多一倍,CopyOnWriteArrayList显然不适合。
上面的示例程序槽点还是比较多的,例如:
- 每个用户分配一次线程(当然,之所以没有爆出问题是因为,程序逻辑并没有走这个逻辑)
- 连接关闭之后线程没有释放
- 计算使用了安全队列,还自己管理和数据队列有关的状态
这些都反应了对多线程理解不够深刻,把自己的业务逻辑和多线程工具类的处理逻辑混在一起了。
3. 回到开始
这其实就是一个生产者与消费者模式的升级版本,只需要封装自己的逻辑就可以了。
当然,这也可能弄的很复杂,例如,弄成想Tomcat那样。
当然,如果想要简单的处理,那基本就是弄一个线程池。
我们的核心任务是把生产数据的逻辑和消费数据的逻辑封装成任务,扔给线程池就可以了。
至于,这个线程池多大、使用什么队列、使用什么拒绝策略,这些细节的东西应该根据实际业务需要,结合运行的统计数据调整。
来源:oschina
链接:https://my.oschina.net/u/2474629/blog/4921297