التدفقات المؤقتة والأداء
في كل مرة تستدعي فيها read() أو write() على تدفق غير مؤقَّت، تُجري JVM نداءً للنظام يعبر الحدود بين فضاء المستخدم وفضاء النواة. على قرص دوّار يستغرق ذلك ميكروثوانٍ؛ وعبر الشبكة قد يستغرق مللي ثوانٍ. والأسوأ أن هذه التكاليف تتضاعف: قراءة ملف بحجم 1 ميغابايت بايتًا بايتًا تعني قرابة مليون نداء للنظام بدلًا من نداء واحد. يقضي التخزين المؤقت (الـ Buffering) على معظم هذا العبء بتجميع النقلات الصغيرة في نقلة كبيرة واحدة.
لماذا يُحدث التخزين المؤقت فارقًا كبيرًا
نظام التشغيل والأجهزة التخزينية تعمل بالفعل على كتل — عادةً من 4 كيلوبايت إلى 64 كيلوبايت في المرة الواحدة. إذا طلب برنامج Java بايتًا واحدًا، قد تضطر النواة إلى جلب كتلة كاملة ثم تهمل الباقي. أما المخزن المؤقت فيحتفظ بالكتلة في الذاكرة لتُخدَّم القراءات اللاحقة منه دون أي نداء للنظام.
قاعدة عملية: غلّف كل تدفق ملفات بتدفق مؤقَّت ما لم يكن لديك سبب محدد لعدم ذلك. تكلفة كائن الغلاف ضئيلة جدًا؛ أما مكسب الأداء فهو شبه دائم ومعتبر.
BufferedInputStream و BufferedOutputStream
للبيانات الثنائية، غلّف FileInputStream أو FileOutputStream بالنظير المؤقَّت:
import java.io.*;
import java.nio.file.Path;
public class BufferedByteDemo {
public static void copyFile(Path src, Path dst) throws IOException {
// بلا تخزين مؤقت: نداء نظام لكل بايت
// مع تخزين مؤقت: نداء نظام لكل كتلة 8 كيلوبايت (الحجم الافتراضي)
try (var in = new BufferedInputStream(new FileInputStream(src.toFile()), 8_192);
var out = new BufferedOutputStream(new FileOutputStream(dst.toFile()), 8_192)) {
byte[] chunk = new byte[8_192];
int bytesRead;
while ((bytesRead = in.read(chunk)) != -1) {
out.write(chunk, 0, bytesRead);
}
// يُستدعى out.flush() تلقائيًا عند الإغلاق عبر try-with-resources
}
}
}
الوسيط الثاني في المُنشئ هو حجم المخزن الداخلي بالبايت. الافتراضي 8 192 بايت (8 كيلوبايت). يمكنك رفعه إلى 64 أو 128 كيلوبايت للقراءات التسلسلية الكبيرة، أو تخفيضه في البيئات المحدودة الذاكرة. نادرًا ما تُظهر المعايير فائدة تتجاوز 64 كيلوبايت على الأجهزة الحديثة.
لا تنسَ الـ flush قبل الإغلاق اليدوي. استدعاء close() يُفرغ المخزن تلقائيًا، لكن إذا أعدت استخدام تدفق عبر عدة عمليات كتابة ومررته لدالة أخرى، استدعِ flush() صراحةً لإرسال المخزن إلى نظام التشغيل.
BufferedReader و BufferedWriter
للبيانات النصية، الغلافان المؤقَّتان هما BufferedReader وBufferedWriter. يُضيفان ميزة جوهرية: readLine() التي تتعامل مع \n و\r\n و\r تلقائيًا.
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
public class BufferedTextDemo {
// قراءة كل سطر من ملف نصي UTF-8
public static void printLines(Path path) throws IOException {
try (var reader = new BufferedReader(
new InputStreamReader(
new FileInputStream(path.toFile()),
StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
// كتابة سطور مع فاصل سطر محايد للمنصة
public static void writeLines(Path path, Iterable<String> lines) throws IOException {
try (var writer = new BufferedWriter(
new OutputStreamWriter(
new FileOutputStream(path.toFile()),
StandardCharsets.UTF_8))) {
for (String line : lines) {
writer.write(line);
writer.newLine(); // يكتب \n على Unix و \r\n على Windows
}
}
}
}
لاحظ نمط التطبيق بالطبقات: BufferedWriter يغلّف OutputStreamWriter (تحويل محارف إلى بايتات) الذي يغلّف FileOutputStream (بايتات إلى القرص). كل طبقة لها مسؤولية واحدة.
الاختصار الحديث: Files.newBufferedReader و Files.newBufferedWriter
منذ Java 7 تُوفر الفئة المساعدة Files دوالَّ مصنع تبني هذا التكديس الثلاثي الطبقات تلقائيًا، مع الاعتماد الافتراضي على UTF-8:
import java.io.*;
import java.nio.file.*;
import java.util.List;
public class NioBufferedDemo {
public static List<String> readAllLines(Path path) throws IOException {
// Files.readAllLines مناسبة للملفات الصغيرة التي تسع في الذاكرة
return Files.readAllLines(path); // UTF-8 افتراضيًا
}
public static void processLargeFile(Path path) throws IOException {
// للملفات الكبيرة: معالجة سطر بسطر دون تحميل كل شيء
try (BufferedReader reader = Files.newBufferedReader(path)) {
reader.lines()
.filter(line -> !line.isBlank())
.map(String::strip)
.forEach(System.out::println);
}
}
public static void writeLinesNio(Path path, List<String> lines) throws IOException {
try (BufferedWriter writer = Files.newBufferedWriter(path)) {
for (String line : lines) {
writer.write(line);
writer.newLine();
}
}
}
}
فضّل Files.newBufferedReader وFiles.newBufferedWriter على التكديس الثلاثي اليدوي — النية أوضح والترميز يعتمد UTF-8 افتراضيًا. احتفظ بالتكديس اليدوي فقط حين تحتاج إلى ترميز غير افتراضي أو مصدر غير ملفي.
PrintWriter للإخراج النصي المُنسَّق
يغلّف PrintWriter كاتبًا مؤقَّتًا ويُضيف println() وprintf() وformat(). مفيد لكتابة ملفات السجلات أو مخرجات CSV حيث تمزج بين سلاسل عادية وأرقام مُنسَّقة:
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
public class PrintWriterDemo {
public static void writeCsv(Path path) throws IOException {
// autoFlush=false: أفرغ المخزن عند الإغلاق فقط، لأفضل أداء
try (var pw = new PrintWriter(Files.newBufferedWriter(path, StandardCharsets.UTF_8))) {
pw.println("name,score,passed");
pw.printf("%s,%.2f,%b%n", "Alice", 92.5, true);
pw.printf("%s,%.2f,%b%n", "Bob", 58.0, false);
}
}
}
تجنّب مُنشئ PrintWriter ذي auto-flush. new PrintWriter(writer, true) يُفرغ المخزن بعد كل println() — هذا مقبول للإخراج التفاعلي على الكونسول لكنه كارثي لكتابة الملفات، إذ يحوّل كل سطر إلى نداء نظام ويُبطل كل فائدة التخزين المؤقت. أبقِ autoFlush=false (الافتراضي) لعمليات I/O الملفات.
قياس الفارق
إليك معيار قياس بسيط يكتب مليون سطر قصير بالطريقتين ويوقّت كل جولة:
import java.io.*;
import java.nio.file.*;
public class BufferBenchmark {
static final int LINES = 1_000_000;
public static void main(String[] args) throws IOException {
Path p1 = Files.createTempFile("unbuffered", ".txt");
Path p2 = Files.createTempFile("buffered", ".txt");
long t1 = System.currentTimeMillis();
try (var fw = new FileWriter(p1.toFile())) { // بلا مخزن
for (int i = 0; i < LINES; i++) fw.write("line " + i + "\n");
}
System.out.println("بلا تخزين مؤقت: " + (System.currentTimeMillis() - t1) + " مللي ثانية");
long t2 = System.currentTimeMillis();
try (var bw = Files.newBufferedWriter(p2)) { // مع مخزن
for (int i = 0; i < LINES; i++) { bw.write("line " + i); bw.newLine(); }
}
System.out.println("مع تخزين مؤقت: " + (System.currentTimeMillis() - t2) + " مللي ثانية");
Files.delete(p1);
Files.delete(p2);
}
}
على SSD نموذجي ستجد النسخة المؤقَّتة أسرع بـ 5 إلى 20 مرة. على قرص دوّار أو حصة شبكية يكون الفارق أكبر.
اختيار حجم المخزن المناسب
- الافتراضي (8 كيلوبايت) — صحيح لتقريبًا كل حالة استخدام. لا تغيّره بدون قياس فعلي.
- 64 – 128 كيلوبايت — قد يُفيد عند قراءة ملفات تسلسلية كبيرة جدًا (تحويل فيديو، معالجة سجلات).
- مطابقة حجم صفحة نظام التشغيل — على Linux حجم صفحة VM هو 4 096 بايت؛ مضاعفاته (8 192، 16 384) تتوافق مع I/O الكتل في النواة.
- مخازن أصغر — مفيدة فقط في البيئات شديدة المحدودية (الأجهزة المدمجة)، وهو مجال نادر لـ Java.
الخلاصة
التدفقات المؤقَّتة هي غلاف بسيط ذو تأثير درامي على الأداء. غلّف دائمًا تدفقات البايتات الخام بـ BufferedInputStream/BufferedOutputStream، وتدفقات المحارف بـ BufferedReader/BufferedWriter. للمسارات في NIO.2 استخدم Files.newBufferedReader وFiles.newBufferedWriter. احتفظ بحجم المخزن الافتراضي 8 كيلوبايت ما لم يُخبرك التوصيف بغير ذلك، ولا تُفعّل auto-flush لمخرجات الملفات أبدًا، واحرص على الإغلاق أو الـ flush قبل انتهاء البرنامج.