通常,我们选择多线程执行任务有两个理由,一是复杂任务采用多线程处理能够在发生并发时让用户减少等待也能防止阻塞,一是充分利用空闲时间,提高任务处理的效率,就后者而言,此处探讨不考虑客户端并发是否有必要把一个任务拆分成多线程来处理。
为了探究多线程的效率问题,我做了一个实验,将不同种类的任务分别用单线程和多线程执行,同时也试验了不同种类的锁机制。测试基于Java 8的版本,希望看到总结可以直接点击到文末。
单一任务的实验集合
任务一 循环打印
开启五个线程执行任务,设定了足够次数的循环输出,输出的数字和当前线程,利用System.currentTimeMillis()统计任务用时。(代码略)以下是相同任务在不同环境下执行多次的平均执行时间。
无锁执行 | 9943 |
synchronized(new Object()) | 9923 |
synchronized("lock") | 6864 |
相同的ReentrantLock() | 6682 |
new ReentrantLock() | 10220 |
串行执行 | 6782 |
其实,由于整段代码都加了锁,锁不同的对象就相当于串行执行,此处执行print任务利用多线程执行反而使效率降低了,因为print过程中cpu几乎没有空闲时间,而线程切换引起的上下文切换却需要很大的时间耗费,所以此处不应采用多线程处理任务。
任务二 读取文件
读取不同的文件测试效率,文件大小约为7M。
Util:
public static String getStr(String filePath) throws FileNotFoundException { return getStr(new FileInputStream(filePath),"<br>"); } public static String getStr(InputStream in,String Enter) { BufferedReader reader = null; StringBuffer sb = new StringBuffer(); try { reader = new BufferedReader(new InputStreamReader(in, "UTF-8")); String s; while((s=reader.readLine()) != null){ sb.append(s+Enter); } reader.close(); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } return sb.toString(); }
Test:
public class MainApplicationTests { class MyTh implements Runnable{ //Lock lock ; Integer n; MyTh(Integer n){ this.n=n; } @Override public void run() { int i=0; synchronized(new Object()) { try { String s = FileUtil.getStr("D://xxx//xxx" + n + ".zip"); System.out.println(s.substring(10, 20)); } catch (IOException e) { e.printStackTrace(); } } } } @Test public void test() throws InterruptedException, FileNotFoundException { Long q=System.currentTimeMillis(); //Thread t=Thread.currentThread(); //ReentrantLock lock = new ReentrantLock(); Thread n=new Thread(new MyTh(1)); Thread n1=new Thread(new MyTh(2)); Thread n2=new Thread(new MyTh(3)); Thread n3=new Thread(new MyTh(4)); Thread n4=new Thread(new MyTh(5)); n.start(); n1.start(); n2.start(); n3.start(); n4.start(); n.join(); n1.join(); n2.join(); n3.join(); n4.join(); System.out.println(System.currentTimeMillis()-q); System.out.println(q); } }
执行时间:
多线程执行 | 24327 |
串行执行 | 17444 |
IO的开启确实有等待时间,此处后面探究。
任务三 HttpClient请求接口
接口访问是典型需要等待结果的,我们利用多线程和单线程访问一个约需2秒的接口。
执行时间:
多线程执行 | 2587 |
串行执行 | 7238 |
这种情境下,多线程的性能极大的发挥了出来,利用线程空闲的等待请求返回时间,可以切换其他线程进行任务。
任务的判断维度
我们普遍认为版本较新的JVM会将多线程任务分发到多个核心上,从而提高执行效率。然而事实并非如此,在实际使用中,我们应认清两个事:任务是否可以被拆解以提高效率;任务拆解后是否能真正的提高效率。在业务逻辑中,如果多个任务不讲究执行的顺序性,那么是可以进行拆分的,而拆解的必要性则从四种维度来判断:
本地与远端
本地下发的远端任务阻塞(接口请求,rpc调用,数据库请求)对于本地服务器资源是消耗很小的,在远端资源请求的阻塞期间,可拆解的任务还有大量的优化空间。
CPU密集与IO密集
需要明确的是,一般来说将CPU密集的任务拆分并不能在性能上有所提高, 因为系统cpu已经到达瓶颈,即使拆分任务也不能提高效率,IO密集则正相反,因为IO的巨大吞吐很难到达瓶颈,IO密集型任务通常很适宜采用多线程拆分任务。
最典型的CPU密集型任务是复杂的运算(才不是循环i++这种),而最典型的IO密集是文件读取、数据读取等。
阻塞与非阻塞
阻塞模型在程序中是非常常见的,譬如上文中调用一个接口等待返回过程便是阻塞接口,使用Future.get()也会阻塞当前线程,这是毋庸质疑应当使用多线程来拆解任务的情形,一般来说,IO密集便意味着阻塞等待,通常比起上下文切换带来的消耗,线程阻塞也更为浪费时间。
种类简单任务和复杂任务
此处的简单任务主要是任务类型单一,主要针对任务种类的复杂性,并不是指任务的执行时间和任务量。对于本地执行类型单一的任务(譬如上面的实验),本地执行的任务一般都会有某个领域的瓶颈(例如内存、磁盘IO、cpu),例如文件读取,即使拆分线程读取,也会受到硬盘IO或是内存的限制。但是种类丰富的任务就很可能通过多线程协调做到更高的执行效率。
正确的优化:多维度衡量
在考虑上文中的诸多因素后,我们需要综合衡量一个任务是否值得被拆分: