并发写文件一般都会使用到锁来着,但这里就不用了,换用BlockingQueue代替一下,也不用操心锁的问题了。
不过如果真得要使用锁的话,ReentrantReadWriteLock是个不错的选择,使用起来大概是这样的:
ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);
try{
lock.writeLock().lock();
//写入
}finally {
lock.writeLock().unlock();
}
和ReentrantLock使用方法基本一样,在日志文件中写入一般会注意顺序,所以使用公平锁也是经常见到。
回到编写这个工具本身来,这里想到的有几个点:
- 日志写入工具本身是单例
- 真正的写入操作一直放在一个子线程中,也就避免了线程安全问题
- 缓冲区先使用BlockingQueue作为了一个缓冲队列,然后真正写入时再使用BufferedWriter再做一次缓冲
- 文件名在某些条件满足下更改
整个写下来就这样:
public class LogFileWriter {
/**
* 写文件专用线程
*/
private ExecutorService ioThread;
/**
* 缓冲区,使用BlockingQueue就无需另外加锁了;
* 越大则写入效率越高,但内存开销就越大,反之效率越低,内存开销越小
*/
private BlockingQueue<String> buffer;
/**
* 缓冲区大小
*/
private static final int BUFFER_SIZE = 100;
/**
* 单个文件大小限制
*/
private static final int MAX_FILE_SIZE = 2 * 1024 * 1024;
/**
* 分别记录上一次IO时间,以及需要变更文件名的时间点
*/
private long recent, tomorrow;
/**
* 用于文件命名
*/
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyyMMdd", Locale.CHINA);
/**
* 调用是在多线程环境下,所以使用ThreadLocal避免线程安全问题
*/
private static final ThreadLocal<SimpleDateFormat> TIME_FORMAT = new ThreadLocal<SimpleDateFormat>() {
@Override
protected SimpleDateFormat initialValue() {
return new SimpleDateFormat("HH:mm:ss.SSS", Locale.CHINA);
}
};
/**
* 文件名,会随着一些条件进行变更
*/
private String fileName;
/**
* 用于文件名命名,主要是超出定制大小后索引自增,从1开始
*/
private AtomicInteger fileIndex;
private static class Holder {
private static LogFileWriter INSTANCE = new LogFileWriter();
}
public static LogFileWriter getInstance() {
return Holder.INSTANCE;
}
private LogFileWriter() {
ioThread = new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<Runnable>(2));
buffer = new ArrayBlockingQueue<>(BUFFER_SIZE);
fileIndex = new AtomicInteger(1);
recent = System.currentTimeMillis();
fileName = String.format(Locale.CHINA, "%s_%02d.log", DATE_FORMAT.format(recent), fileIndex.get());
tomorrow = checkTomorrow();
flushForever();
}
/**
* 外部调用入口
* @param content 内容
*/
public void write(String content) {
try {
buffer.put(TIME_FORMAT.get().format(System.currentTimeMillis()) + " /" + content);
// buffer.put(content);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* 永远监听写入时机
*/
private void flushForever() {
ioThread.execute(new Runnable() {
@Override
public void run() {
while (true) {
if (buffer.size() > BUFFER_SIZE - 1 || (buffer.size() > 0 && System.currentTimeMillis() - recent > 10 * 1000)) {
flush();
}
}
}
});
}
/**
* 写入,使用BufferedWriter再做一次缓冲
*/
private void flush() {
try {
BufferedWriter fw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(getFileName(), true), StandardCharsets.UTF_8));
int size = buffer.size();
for (int i = 0; i < size; i++) {
fw.write(buffer.take());
}
fw.flush();
fw.close();
} catch (IOException | InterruptedException e) {
e.printStackTrace();
} finally {
recent = System.currentTimeMillis();
System.out.println("flush at " + recent);
}
}
/**
* 在两种情况下变更文件名,一是文件大小超过限制,二是时间已非当天
*
* @return filename
*/
private String getFileName() {
if (System.currentTimeMillis() > tomorrow) {
//判断时间是否已经过了凌晨0点,到了第二天
tomorrow = checkTomorrow();
fileIndex.set(1);
fileName = String.format(Locale.CHINA, "%s_%02d.log", DATE_FORMAT.format(System.currentTimeMillis()), fileIndex.get());
} else {
//判断文件是否超过大小
File file = new File(fileName);
if (file.exists() && file.length() > MAX_FILE_SIZE) {
fileName = String.format(Locale.CHINA, "%s_%02d.log", DATE_FORMAT.format(System.currentTimeMillis()), fileIndex.incrementAndGet());
}
}
return fileName;
}
/**
* 得到时间临界点,这里设为两天之交凌晨0点,系统所提供的System.currentTimeMillis()是从1970年1月1日8点从开始算起
*
* @return TimeMills
*/
private long checkTomorrow() {
return System.currentTimeMillis() - 8 * 3600 * 1000 + (24 * 3600 * 1000 - System.currentTimeMillis() % (24 * 3600 * 1000));
}
}
写下来,真正的写入操作全靠BlockingQueue和BufferedWriter,其他并没有什么好说的;
BlockingQueue由于自身的特性就比较适用于多线程环境,所以不用担心并发调用write方法会出什么问题,调用时使用put,然后工具中一直有个子线程在take,queue中没有数据就一直阻塞等待;BufferedWriter就不说了,写入大量数据时必备。
最后还是要测试一下:
public class CurrentFileWriteDemo {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 100000; j++) {
LogFileWriter.getInstance().write(System.currentTimeMillis() + "---------" + Thread.currentThread().getName() + "------" + j + "\r\n");
}
}
}).start();
}
}
}
这里用5个线程共写入50万条数据,最终成果是这样的:
这里的文件大小并不是一样大的,因为代码中每次写入是有条件的,基本是缓冲队列将满才写入一次,减小IO次数也是提高效率的好方法,所以缓冲队列大小对效率影响比较大。
第一个文件的第一条数据和最后一个文件的最后一条数据时间分别是:
20190606_01.log
09:25:21.098 /1559784321098---------Thread-2------0
20190606_14.log
09:25:24.861 /1559784324861---------Thread-2------99999
耗时3.763s。
如果写入内容总量不变,缓冲队列大小也不变,把调用处线程数加多,那么耗时会变长;
如果写入内容问题不变,调用处线程数不变,缓冲队列加大,那么耗时会减少,不过就变成时间换空间或是空间换时间的问题了。
就这样。