Java死锁——以NIO网络编程为例
事故背景
我在写 Java1.4从BIO模型发展到NIO模型 时,就有个问题:
- 是否可以用 Acceptor线程 和 I/O 线程分别处理 接收连接 和 读写 ?
于是我想到"acceptor"线程循环执行 ServerSocketChannel.accept(),然后再注册事件。
另外启动一个"selector-io"线程刷新键集 Selector.select() 和处理键集 Set<SelectionKey> 。结果却出现了死锁,囧。
编写服务端
我们设计将 accept() 和 select() 分别放在主线程和"selector-io"线程中循环,一旦 accept() 获取到 SocketChannel 对象就立刻注册 OP_READ 事件到 Selector。
public class JavaChannelDeadLock {
// TCP 事件
@Test
public void tcpSocketTest() throws IOException {
ServerSocketChannel channel = ServerSocketChannel.open();
channel.bind(new InetSocketAddress(8080));
Selector selector = Selector.open();
new Thread(() -> {
try {
dispatch(selector);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
},"selector-io").start();
while (true) {
SocketChannel socket = channel.accept();
socket.configureBlocking(false);
socket.register(selector,SelectionKey.OP_READ);
System.out.println("已注册"+socket);
}
}
private void dispatch( Selector selector) throws IOException, InterruptedException {
while (true) {
int count = selector.select();
if (count==0) {
continue;
}
//客户端断开 事件处理
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (!key.isValid()) {
continue;
} else if (key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
ByteBuffer dst=ByteBuffer.allocate(1024);
channel.read(dst);
System.out.println("收到消息");
}
}
}
}
}
启动服务端
运行上一节的服务端代码,我们启动之后来看一下线程 dump 情况:
如上图所示,主线程("main")拿到了锁- locked <0x00000000d6092df0> (a java.lang.Object),然后调用 native accept0 方法,发生CPU中断。
- 这把锁即 sun.nio.ch.ServerSocketChannelImpl 的成员变量 private final Object lock = new Object();。
I/O 线程("selector-io")在执行 sun.nio.ch.SelectorImpl.lockAndDoSelect 时依次拿到了三把锁:
- 第一把锁 - locked <0x00000000d609c1b0> (a sun.nio.ch.WindowsSelectorImpl) 是通过 synchronized(this) {} 获取到的;
- 第二把锁 - locked <0x00000000d609e728> (a java.util.Collections$UnmodifiableSet) 是通过 synchronized(this.publicKeys) {} 获取到的;
- 第三把锁 - locked <0x00000000d609efc0> (a sun.nio.ch.Util$3) 是通过 synchronized(this.publicSelectedKeys) {} 获取到的;
最后调用 native poll0 方法,发生CPU中断。
补充一下
我们 sun.nio.ch.SelectorImpl 的构造函数中看到成员变量 publicKeys:Set<SelectionKey> 和 publicSelectedKeys:Set<SelectionKey>的初始化过程。代码如下:
protected SelectorImpl(SelectorProvider var1) {
super(var1);
if (Util.atBugLevel("1.4")) {
this.publicKeys = this.keys;
this.publicSelectedKeys = this.selectedKeys;
} else {
this.publicKeys = Collections.unmodifiableSet(this.keys);
this.publicSelectedKeys = Util.ungrowableSet(this.selectedKeys);
}
}
启动Telnet服务端发生死锁
回车之后,我们随便输入几个字符,发送给服务端,此时服务端完全没有响应!此时,已经产生死锁!
我们来观察一下此时的线程 dump 文件:
此时线程已经由 RUNNABLE 变为 BLOCKED,主线程正在等待 publicKeys 对象锁的释放。
在类 sun.nio.ch.SelectorImpl 的 protected final SelectionKey register(AbstractSelectableChannel channel, int ops, Object attachment) 方法中,
语句 synchronized(this.publicKeys) {} 等待的是lock <0x00000000d609e728> (a java.util.Collections$UnmodifiableSet)。
Selector.select() 获得 publicKeys 对象锁,等待就绪事件。就绪事件的前提是注册事件,但是 SocketChannel.register() 方法尝试竞争 publicKeys 对象锁时进入阻塞状态,无法成功注册事件。
"selector-io"线程等待就绪事件,陷入无限等待。"main"线程等待 publicKeys 对象锁,陷入无限等待。
修改方案
改用 Selector.select(int timeout) 代替 Selector.select(),那么 SelectableChannel.register(Selector sel, int ops) 就有机会竞争锁了,但是还是可能出现“饿死”现象,即长时间竞争不到锁而等待。
因此再加上一个 Thread.sleep(5),保证在 select(500) 超时并且释放锁之后,register 方法有足够的时间来获取到锁。
// int count = selector.select();
int count = selector.select(500);
Thread.sleep(5);// 防止死锁 导致注册不上
这样做,虽然能够避免死锁,但是性能上还是有损耗。