AIO寫文件的OutOfMemoryError

問題重現

AIO進行寫文件使用了AsynchronousFileChannel類來實現,測試代碼如下:

public class AsynchronousFileChannelTest {
    private static final String outputPath = "output.txt";
    private static String data = "你好";

    public static void main(String[] args) throws IOException {
        Path path = Paths.get(outputPath);
        if (!Files.exists(path)) {
            Files.createFile(path);
        }
        AsynchronousFileChannel fileChannel =
                AsynchronousFileChannel.open(path, StandardOpenOption.WRITE);

        ByteBuffer buffer = ByteBuffer.allocate(1024);
        long position = 0;

        buffer.put(data.getBytes());
        buffer.flip();

        for (int i = 0; i < 10000000; i++) {
            fileChannel.write(buffer, position, buffer, new CompletionHandler<Integer, ByteBuffer>() {

                @Override
                public void completed(Integer result, ByteBuffer attachment) {
                    System.out.println("bytes written: " + result);
                }

                @Override
                public void failed(Throwable exc, ByteBuffer attachment) {
                    System.out.println("Write failed");
                    exc.printStackTrace();
                }
            });
            position += data.getBytes().length;
        }

    }
}

執行結果如下:

java.lang.OutOfMemoryError: unable to create new native thread
    at java.lang.Thread.start0(Native Method)
    at java.lang.Thread.start(Thread.java:714)
    at java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:950)
    at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1368)
    at sun.nio.ch.Invoker.invokeIndirectly(Invoker.java:236)
    at sun.nio.ch.SimpleAsynchronousFileChannelImpl.implWrite(SimpleAsynchronousFileChannelImpl.java:359)
    at sun.nio.ch.AsynchronousFileChannelImpl.write(AsynchronousFileChannelImpl.java:251)
    at cn.ideabuffer.interview.test.io.AsynchronousFileChannelTest.main(AsynchronousFileChannelTest.java:34)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:497)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)

可見,該問題是內存溢出,不能創建新的線程。

查看原因

那么,為什么會創建這么多的線程呢?

我們先來看一下AsynchronousFileChannelImpl類的write方法:

public final <A> void write(ByteBuffer var1, long var2, A var4, CompletionHandler<Integer, ? super A> var5) {
    if(var5 == null) {
        throw new NullPointerException("\'handler\' is null");
    } else {
        this.implWrite(var1, var2, var4, var5);
    }
}

這里調用了implWrite方法,implWrite方法是在SimpleAsynchronousFileChannelImpl類中定義的,下面來看一下SimpleAsynchronousFileChannelImpl類的implWrite方法:注意:因為我是在Mac OS上進行測試,windows下是沒有SimpleAsynchronousFileChannelImpl類的

<A> Future<Integer> implWrite(final ByteBuffer var1, final long var2, final A var4, final CompletionHandler<Integer, ? super A> var5) {
    if(var2 < 0L) {
        throw new IllegalArgumentException("Negative position");
    } else if(!this.writing) {
        throw new NonWritableChannelException();
    } else if(this.isOpen() && var1.remaining() != 0) {
        final PendingFuture var8 = var5 == null?new PendingFuture(this):null;
        Runnable var7 = new Runnable() {
            public void run() {
                // 省略一些代碼
                ...

            }
        };
        this.executor.execute(var7);
        return var8;
    } else {
        ClosedChannelException var6 = this.isOpen()?null:new ClosedChannelException();
        if(var5 == null) {
            return CompletedFuture.withResult(Integer.valueOf(0), var6);
        } else {
            Invoker.invokeIndirectly(var5, var4, Integer.valueOf(0), var6, this.executor);
            return null;
        }
    }
}

看一下第15行和第22行,這里都使用了executor來執行具體的寫操作,而executor是在哪里定義的呢?

由于創建AsynchronousFileChannel對象的時候是如下代碼:

AsynchronousFileChannel fileChannel =
                AsynchronousFileChannel.open(path, StandardOpenOption.WRITE);

AsynchronousFileChannel的open方法定義如下:

public static AsynchronousFileChannel open(Path file, OpenOption... options)
        throws IOException
{
    Set<OpenOption> set = new HashSet<OpenOption>(options.length);
    Collections.addAll(set, options);
    return open(file, set, null, NO_ATTRIBUTES);
}

這里調用了重載的open方法,注意第三個參數為null,該參數的類型就是ExecutorService,查看該方法:

public static AsynchronousFileChannel open(Path file,
                                               Set<? extends OpenOption> options,
                                               ExecutorService executor,
                                               FileAttribute<?>... attrs)
        throws IOException
{
    FileSystemProvider provider = file.getFileSystem().provider();
    return provider.newAsynchronousFileChannel(file, options, executor, attrs);
}

這里的provider是UnixFileSystemProvider,查看該類的newAsynchronousFileChannel方法:

public AsynchronousFileChannel newAsynchronousFileChannel(Path var1, Set<? extends OpenOption> var2, ExecutorService var3, FileAttribute... var4) throws IOException {
    UnixPath var5 = this.checkPath(var1);
    int var6 = UnixFileModeAttribute.toUnixMode(438, var4);
    ThreadPool var7 = var3 == null?null:ThreadPool.wrap(var3, 0);

    try {
        return UnixChannelFactory.newAsynchronousFileChannel(var5, var2, var6, var7);
    } catch (UnixException var9) {
        var9.rethrowAsIOException(var5);
        return null;
    }
}

調用了UnixChannelFactory的newAsynchronousFileChannel方法,該方法代碼如下:

static AsynchronousFileChannel newAsynchronousFileChannel(UnixPath var0, Set<? extends OpenOption> var1, int var2, ThreadPool var3) throws UnixException {
    UnixChannelFactory.Flags var4 = UnixChannelFactory.Flags.toFlags(var1);
    if(!var4.read && !var4.write) {
        var4.read = true;
    }

    if(var4.append) {
        throw new UnsupportedOperationException("APPEND not allowed");
    } else {
        FileDescriptor var5 = open(-1, var0, (String)null, var4, var2);
        return SimpleAsynchronousFileChannelImpl.open(var5, var4.read, var4.write, var3);
    }
}

這里就用到了SimpleAsynchronousFileChannelImpl的open方法:

public static AsynchronousFileChannel open(FileDescriptor var0, boolean var1, boolean var2, ThreadPool var3) {
    ExecutorService var4 = var3 == null?SimpleAsynchronousFileChannelImpl.DefaultExecutorHolder.defaultExecutor:var3.executor();
    return new SimpleAsynchronousFileChannelImpl(var0, var1, var2, var4);
}

可以看到,這里的ExecutorService對象使用了DefaultExecutorHolder中的defaultExecutor:

private static class DefaultExecutorHolder {
    static final ExecutorService defaultExecutor = ThreadPool.createDefault().executor();

    private DefaultExecutorHolder() {
    }
}

再看一下ThreadPool的createDefault方法:

static ThreadPool createDefault() {
    int var0 = getDefaultThreadPoolInitialSize();
    if(var0 < 0) {
        var0 = Runtime.getRuntime().availableProcessors();
    }

    ThreadFactory var1 = getDefaultThreadPoolThreadFactory();
    if(var1 == null) {
        var1 = defaultThreadFactory();
    }

    // 創建executor
    ExecutorService var2 = Executors.newCachedThreadPool(var1);
    return new ThreadPool(var2, false, var0);
}

可以看到,這里默認創建了一個CachedThreadPool,在newCachedThreadPool方法中使用了SynchronousQueue作為任務隊列:

public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>(),
                                  threadFactory);
}

這里注意第二個參數,第二個參數是設置線程池最大的任務數量,有關線程池請參考之前的文章深入理解Java線程池:ThreadPoolExecutor

也就是說,這里的任務數量是沒有限制的,而SynchronousQueue這個隊列比較特殊,它是一個沒有數據緩沖的BlockingQueue(隊列只能存儲一個元素),生產者線程對其的插入操作put必須等待消費者的移除操作take,反過來也一樣,消費者移除數據操作必須等待生產者的插入。

不像ArrayBlockingQueue或LinkedListBlockingQueue,SynchronousQueue內部并沒有數據緩存空間,你不能調用peek()方法來看隊列中是否有數據元素,因為數據元素只有當你試著取走的時候才可能存在,不取走而只想偷窺一下是不行的,當然遍歷這個隊列的操作也是不允許的。隊列頭元素是第一個排隊要插入數據的線程,而不是要交換的數據。數據是在配對的生產者和消費者線程之間直接傳遞的,并不會將數據緩沖數據到隊列中。可以這樣來理解:生產者和消費者互相等待對方,握手,然后一起離開。

根據我們的測試代碼來看,寫文件的時候會向executor中添加一個線程作為任務來執行,而這時如果磁盤的寫速度太慢,而程序在不停地進行寫任務的添加,這會導致隊列中的對象越來越多,而隊列中的對象就是Runnable對象,也就是線程對象。可以在報錯信息中看到,異常是在Invoker類中:

static <V, A> void invokeIndirectly(final CompletionHandler<V, ? super A> var0, final A var1, final V var2, final Throwable var3, Executor var4) {
    try {
        var4.execute(new Runnable() {
            public void run() {
                Invoker.invokeUnchecked(var0, var1, var2, var3);
            }
        });
    } catch (RejectedExecutionException var6) {
        throw new ShutdownChannelGroupException();
    }
}

這里執行的時候會創建一個線程對象,在調用了execute方法之后,會調用線程池中的addWorker方法添加任務:

private boolean addWorker(Runnable firstTask, boolean core) {
    
    ...
    
    boolean workerStarted = false;
    boolean workerAdded = false;
    Worker w = null;
    try {
        w = new Worker(firstTask);
        final Thread t = w.thread;
        if (t != null) {
            final ReentrantLock mainLock = this.mainLock;
            mainLock.lock();
            try {
                ...            
            } finally {
                mainLock.unlock();
            }
            if (workerAdded) {
                t.start();
                workerStarted = true;
            }
        }
    } finally {
        if (! workerStarted)
            addWorkerFailed(w);
    }
    return workerStarted;
}

在添加任務完成后,會調用start方法來啟動線程。

所以,在磁盤寫速度比較慢的時候,不停地向線程池中添加線程對象并啟動線程,而且隊列的大小沒有限制。

但這個異常并不是堆內存的溢出,堆內存的溢出如下:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

問題分析

那么,究竟為什么會報不能創建線程的異常呢?

我們先把內存按區域進行以下分類:

  • MaxProcessMemory:指的是一個進程的最大內存
  • JVMMemory:JVM內存
  • ReservedOsMemory:保留的操作系統內存
  • ThreadStackSize:線程棧的大小

在java語言里, 當你創建一個線程的時候,虛擬機會在JVM內存創建一個Thread對象同時創建一個操作系統線程,而這個系統線程的內存用的不是JVMMemory,而是系統中剩下的內存(MaxProcessMemory - JVMMemory - ReservedOsMemory)。

具體計算公式如下:

(MaxProcessMemory - JVMMemory - ReservedOsMemory) / (ThreadStackSize) = Number of threads 

我們測一下如下代碼:

public class TestNativeOutOfMemoryError {

    public static void main(String[] args) {

        for (int i = 0;; i++) {
            System.out.println("i = " + i);
            new Thread(new HoldThread()).start();
        }
    }

}

class HoldThread extends Thread {
    CountDownLatch cdl = new CountDownLatch(1);

    public HoldThread() {
        this.setDaemon(true);
    }

    public void run() {
        try {
            cdl.await();
        } catch (InterruptedException e) {
        }
    }
}

該代碼不停地創建線程,看下結果:

i = 4072
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread

最終停在了4072,也就是創建了4073個線程后報OOM。

查看一下系統的線程數量限制:

sangjiandeMBP:~ sangjian$ sysctl kern.num_taskthreads
kern.num_taskthreads: 4096

可見,系統的線程數量限制為4096,從這個數量來說,和我們運行的結果是一致的。

所以,第一個異常Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread并不一定代表是系統內存不足導致的溢出,也可能是創建的線程數量達到了系統的限制。

解決問題

  1. 如果程序中有bug,導致創建大量不需要的線程或者線程沒有及時回收,那么必須解決這個bug,修改參數是不能解決問題的;

  2. 如果程序確實需要大量的線程,現有的設置不能達到要求,那么可以通過修改MaxProcessMemory,JVMMemory,ThreadStackSize這三個因素,來增加能創建的線程數:

    • MaxProcessMemory 使用64位操作系統
    • JVMMemory 減少JVMMemory的分配
    • ThreadStackSize 減小單個線程的棧大小
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容