在Java的世界中,新开一个线程很容易,你只需要用下面的代码。
| |
在深入一点,你应该知道Thread.run()调用了Linux的fork()函数,从父线程中copy出一个一摸一样的子线程,然后开出了一个新的线程。
但是呢?在深入思考一下,提一个问题:
为啥说Go可以随意Goroutine几百几万个?而Java确不建议开那么多的线程,都是建议使用线程池来处理问题?或者说代码里面随意的使用new Thread()可以吗?
答案就是: Go提供的线程是协程是语言层面的,线程切换不必牵涉CPU上下文切换(而且还提供了Forkjoin机制,当有单个协程阻塞,可以分配),Java的线程使用的是Linux的fork(),会涉及CPU上下文切换。
关于协程
关于Go的协程,更加细致的介绍可以看这两篇文章:
https://blog.csdn.net/truexf/article/details/50510073
https://www.jianshu.com/p/533d58970397
可以看到,由于Java的线程切换,涉及从用户态切换到内核态,多了一步操作。如果Java的线程过多就会造成CPU频繁上下文切换,浪费额外时间。所以,开发协程API对于语言来说还是有不小的价值。(是不是觉得Java有点弱,协程都不支持。)
因此在Java世界还是需要合理的使用线程,无论是GC线程还是业务线程。比较经典的例子就是,web服务器的线程池配置。当我们知道Java开了过多的线程之后反而会减低性能,那么我们的web服务器应该如何调优线程池配置?
以NIO模型为例,分为Accept、Selector、还有业务线程,这里的线程池该如何分配呢?
NIO配置: Jetty
全局使用一个线程池QueuedThreadPool,而最小线程数8最大200,Acceptor线程默认1个,Selector线程数默认2个
NIO配置: undertow
undertow 配置的是 Acceptor 递归也就是线程数 1,IO worker是CPU核数,而工作线程数是CPU * 8;
参考:
https://www.cnblogs.com/maybo/p/7784687.html
https://blog.csdn.net/rickiyeat/article/details/78906366
NIO配置: 自己使用Netty来实现
public class NettyHttpController {
public void run() throws InterruptedException {
// accept 线程池,默认为1
EventLoopGroup boss = new NioEventLoopGroup();
// selector 线程池,
EventLoopGroup worker = new NioEventLoopGroup(Integer CPU_Num);
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(boss, worker);
bootstrap.channel(NioServerSocketChannel.class);
bootstrap.childHandler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel channel) throws Exception {
ChannelPipeline channelPipeline = channel.pipeline();
// 业务线程池,伪代码,这里需要开发考虑
channel.add(new Executor(() -> {
doService();
....
channel.respnse("调用成功");
}));
}
});
ChannelFuture channelFuture = bootstrap.bind(9999).sync();
}
public static void main(String[] args) throws InterruptedException {
new NettyHttpController().run();
}
}
线程模型
如果不考虑网络IO等因素,只考虑多线程业务系统的情况,又该如何处理线程池呢?是需要模仿NIO对线程进行分块处理还是怎么样呢?
在代码世界中,如果语言能力比不上人家,那么构建一个好的模型,同样能够击败对手。在《七周七并发》这本书中就推荐了7种并发模型。比较类似的,就是CSP模型(管道模型)和Actor模型,使用Go和Java分别能很好的实现这两者。
CSP模型
-
接收int 数组,并返回一个channel
func gen(nums ...int) <-chan int { out := make(chan int) go func() { for _, n := range nums { out <- n } close(out) }() return out }
-
从channel中接收数据,并进行2次方,并放到一个新的channel
func sq(in <-chan int) <-chan int { out := make(chan int) go func() { for n := range in { out <- n * n } close(out) }() return out }
-
来看例子一: 如何使用刚刚的两个channel
-
func main() { c := gen(2, 3) // 函数一 out := sq(c) // 函数二 // 消费输出结果 fmt.Println(<-out) // 4 fmt.Println(<-out) // 9 }
-
例子二: channel和Actor不同的是,channel关注channel,而不是接收消息的主体。因此,你还可以将一个channel发送给多个函数消费。
func main() { in := gen(2, 3) // 启动两个 sq 实例,即两个goroutines处理 channel "in" 的数据 c1 := sq(in) c2 := sq(in) // merge 函数将 channel c1 和 c2 合并到一起,这段代码会消费 merge 的结果 for n := range merge(c1, c2) { fmt.Println(n) // 打印 4 9, 或 9 4 } }
Actor模型
不同于channel,Actor模型更加类似人类世界的交互模式。任何的交互都是异步的,都需要容忍失败。
public class Hello1 {
public static void main(String[] args) {
ActorSystem system = ActorSystem.create("actor-demo-java");
ActorRef hello = system.actorOf(Props.create(Hello.class));
// 需要定义接收者
hello.tell("Bob", ActorRef.noSender());
// 有一个线程单独处理,都是异步的,需要容忍失败
try {
Thread.sleep(1000);
} catch (InterruptedException e) { /* ignore */ }
system.shutdown();
}
private static class Hello extends UntypedActor {
public void onReceive(Object message) throws Exception {
if (message instanceof String) {
System.out.println("Hello " + message);
}
}
}
}
对于我而言,Actor更类似于将消息系统引入到了单机业务中来,他更面向于各种复杂的情况。更类似一个完整的业务情况。
总结
本文从Java线程的实现,到NIO模式线程池配置,再到并发模型,来讲解了如何使用多线程。可以看到一个同步线程是最最简单,但是对于CPU而言,浪费类很多调度时间。如果对业务进行抽象,合理进行建模。无论是针对业务,还是针对系统性能,都能有很大的提升。