本专栏前文已介绍完成索引模块程序:
目录
[1. 验耗时](#1. 验耗时)
[2. 多线程制作索引](#2. 多线程制作索引)
[2.1 关于CountDownLatch](#2.1 关于CountDownLatch)
[2.2 关于线程加锁](#2.2 关于线程加锁)
[2.3 关于守护线程](#2.3 关于守护线程)
[3. 修改后的Parser类的run方法](#3. 修改后的Parser类的run方法)
1. 验耗时
在Paser类中的run方法内打时间戳检验耗时操作:
java
public void run(){
long beg = System.currentTimeMillis();
System.out.println("开始构造索引");
long Beg = System.currentTimeMillis();
// 1、根据加载文档路径,枚举该路径目录及其子目录下的所有文件(html)
ArrayList<File> fileList=new ArrayList<>();
// INPUT-PATH表示开始进行递归遍历的起始目录
// fileList表示递归遍历的结果
enumFile(INPUT_PATH, fileList);
long enumFileEnd = System.currentTimeMillis();
System.out.println("枚举文件耗时:"+(enumFileEnd-Beg)+" ms");
// 2、根据罗列出的文件路径打开文件,读取文件内容,进行解析并构建索引
for(File f: fileList){
// parseHTML方法用于解析单个HTML文件
System.out.println("开始解析 "+f.getAbsolutePath());
parseHTML(f);
}
long forEnd = System.currentTimeMillis();
System.out.println("遍历文件耗时:"+(forEnd-enumFileEnd)+" ms");
// 3、把内存中构造的索引数据结构保存到指定文件中
index.save();
System.out.println("完成构造索引");
long end = System.currentTimeMillis();
System.out.println("构建索引耗时:"+(end - beg)+" ms ");
}
再次启动Paser类,获取耗时如下:

(省略中间各html文件的遍历输出结果)

可见在构建索引的枚举、遍历和保存三个操作中,遍历文件耗时占比最高,现基于该问题进行制作索引的优化,考虑使用多线程制作索引。
2. 多线程制作索引
2.1 关于CountDownLatch
CountDownLatch是一个同步辅助工具,允许一个或多个线程等待其他线程完成操作,其实现线程同步的思想是计数器思想,countDown方法实现减少计数器值,await方法实现等待计数器清零。
通过submit向线程池里提交任务,只是把Runnable对象放到阻塞队列中,并不代表线程池中的文档在submit提交完成后也被全部解析完了。为了保证执行save时保存的是完整的解析后的全部文档,采用CountDownLatch的await方法来表示所有任务都完成。
2.2 关于线程加锁
在Index类中有一个addDoc方法,会调用buildForward方法和buildInverted方法,buildForward方法会修改forwardIndex,buildInverted方法会修改invertedIndex,四个线程并发调用addDoc时就存在线程安全问题,需要加锁来解决线程安全问题。
如果直接把synchronized加到parseHTML或addDoc上,加锁粒度太粗使得并发程度较低,需要再细致地考虑加锁的粒度。
在buildForwad方法中,设置docId和将新doc插入到正排索引中两个操作需要加锁:
java
synchronized (locker1){
docInfo.setDocId(forwardIndex.size());
forwardIndex.add(docInfo);
}
在buildInverted方法中,在倒排拉链中根据关键词去倒排索引中查找的结果的操作都需要加锁:
java
synchronized (locker2){
List<Weight> invertedList = invertedIndex.get(entry.getKey());
// 如果为空则插入新键值对
if(invertedList == null){
ArrayList<Weight> newInvertedList = new ArrayList<>();
// 把当前的文档信息docInfo构造成Weight对象
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
// 假定权重公式:标题中出现的次数*10+正文中出现的次数*1
weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
newInvertedList.add(weight);
invertedIndex.put(entry.getKey(),newInvertedList);
}else{
//非空则将当前文档信息docInfo构造成Weight对象插入倒排拉链
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
invertedList.add(weight);
}
}
且注意二者并不竞争同一锁资源,故创建的locker1和locker2为不同锁资源:
java
// 新创建两个锁对象
private Object locker1 = new Object();
private Object locker2 = new Object();
2.3 关于守护线程
如果一个线程是守护线程(后台线程),则这个线程的运行状态不会影响到进程结束。
如果一个线程不是守护线程,则这个线程的运行状态就会影响到进程结束。
之前我们采用的是线程池创建线程:
java
ExecutorService executorService = Executors.newFixedThreadPool(4);
默认创建出来的都是非守护线程,故当main方法执行完后这些线程仍然在等待新任务,并未终止,需要使用shutdown方法进行手动终止。

可见使用多线程后,构建索引耗时由17s将至7s,效率得到了提升。
3. 修改后的Parser类的run方法
java
/*
* 优化制作索引:多线程制作索引
* */
public void run() throws InterruptedException {
long beg = System.currentTimeMillis();
System.out.println("开始构建索引");
// 1. 枚举文件:
ArrayList<File> files = new ArrayList<>();
enumFile(INPUT_PATH, files);
// 2. 多线程循环遍历文件:
CountDownLatch latch = new CountDownLatch(files.size());
ExecutorService executorService = Executors.newFixedThreadPool(4);
for(File f: files){
// 通过submit向线程池里提交任务,只是把Runnable对象放到阻塞队列中
executorService.submit(new Runnable() {
@Override
public void run() {
System.out.println("解析: "+f.getAbsolutePath());
parseHTML(f);
latch.countDown();
}
});
}
// 3. 待所有文件解析完成后再保存索引:
latch.await();
// 手动终止线程池中的所有线程
executorService.shutdown();
index.save();
long end = System.currentTimeMillis();
System.out.println("完成构建索引");
System.out.println("构建索引耗时:"+(end-beg)+" ms");
}