Command Palette

Search for a command to run...

[Java Basics] Nhập xuất dữ liệu trong Java với Scanner và bẫy nextLine

Mọi chương trình từ đầu series tới giờ đều có dữ liệu viết cứng trong source. Bài này lấy dữ liệu từ bên ngoài: System.in cho input, System.outSystem.err cho output. Trong Java, cửa chính để đọc System.injava.util.Scanner.

Scanner rất dễ bắt đầu và cũng đầy cạm bẫy. Cái nổi tiếng nhất làm nextLine() trả về chuỗi rỗng mà không có dấu hiệu gì, và nó đã ngốn của người mới học vô số giờ. Tất cả đều bắt nguồn từ một điều duy nhất: cách Scanner nhìn input của bạn. Nên bài viết bắt đầu từ đó. Mọi chương trình và mọi thông báo lỗi bên dưới đều được compile và chạy thật trên OpenJDK 21.0.6.

Nhập dữ liệu trong Java với Scanner

Nói về output trước, vì bạn cần nó để nhìn thấy đoạn code đọc input thật sự đã đọc được gì.

Xuất dữ liệu: print, println và printf

System.out cho bạn ba cách để ghi ra màn hình:

public class OutputDemo {
    public static void main(String[] args) {
        System.out.print("A");
        System.out.print("B");
        System.out.println("C");
        System.out.println("next line");
        System.out.printf("%s is %d years old%n", "An", 25);
        System.err.println("this went to stderr");
    }
}
ABC
next line
An is 25 years old
this went to stderr
  • print ghi ra rồi để con trỏ nguyên chỗ cũ. Đây chính là thứ bạn cần cho một lời nhắc: System.out.print("Age: ") giữ con trỏ nằm cùng dòng với câu hỏi.
  • println ghi ra rồi xuống dòng.
  • printf ghi ra một khuôn mẫu, các chỗ trống được điền bằng các argument phía sau.

Các format specifier của printf bạn sẽ dùng nhiều nhất

Một specifier gồm %, các flag và độ rộng tuỳ chọn, rồi tới chữ cái chỉ kiểu chuyển đổi. Đây là tập hợp đáng thuộc lòng, mỗi dòng được chạy riêng:

System.out.printf("%s%n", "An Nguyen");
System.out.printf("%d%n", 42);
System.out.printf("%f%n", 3.14159);
System.out.printf("%.2f%n", 3.14159);
System.out.printf("[%-10s]%n", "left");
System.out.printf("[%5d]%n", 42);
System.out.printf("%,d%n", 1234567);
System.out.printf("%b%n", true);
System.out.printf("100%%%n");
An Nguyen
42
3.141590
3.14
[left      ]
[   42]
1,234,567
true
100%
SpecifierÝ nghĩaKết quả ở trên
%sgiá trị bất kỳ, qua toString()An Nguyen
%dcác integer type42
%fsố thực, mặc định 6 chữ số thập phân3.141590
%.2fsố thực, 2 chữ số thập phân, có làm tròn3.14
%-10schuỗi đệm cho đủ rộng 10, canh tráileft
%5dsố nguyên đệm cho đủ rộng 5, canh phải 42
%,dsố nguyên có dấu phân cách hàng nghìn1,234,567
%bbooleantrue
%%một dấu phần trăm%
%ndấu xuống dòngkết thúc dòng

Chữ cái chuyển đổi phải khớp với type của argument. Đưa một double vào %d không phải là yêu cầu làm tròn, mà là một lỗi làm chết chương trình:

System.out.printf("%d%n", 3.14);
Exception in thread "main" java.util.IllegalFormatConversionException: d != java.lang.Double
	at java.base/java.util.Formatter$FormatSpecifier.failConversion(Formatter.java:4515)

Nên dùng %n hay \n?

\n là một ký tự, line feed, byte 10. Còn %n là chỉ thị của printf, nó nở ra thành dấu xuống dòng của hệ thống: line feed trên Linux và macOS, carriage return cộng line feed trên Windows. Trên máy này hai thứ cho ra byte y hệt nhau:

System.out.println("line.separator bytes: "
        + java.util.Arrays.toString(System.lineSeparator().getBytes()));
System.out.printf("A%n");
System.out.printf("B\n");
line.separator bytes: [10]
A
B
java NewlineDemo | tail -2 | od -c
0000000    A  \n   B  \n
0000004

Trong printf hãy dùng %n. Nó là cái chạy đúng trên mọi hệ thống mà chẳng tốn gì thêm. Lưu ý \n vẫn dùng được trong printf, còn %n thì không có tác dụng trong println — lệnh println("A%n") in ra đúng hai ký tự %n.

System.out và System.err

Cả hai đều ghi ra terminal nên nhìn trên màn hình y hệt nhau. Thực ra chúng là hai stream khác nhau, và shell tách được:

java OutputDemo 2>/dev/null      # chỉ stdout
java OutputDemo 2>&1 1>/dev/null # chỉ stderr
ABC
next line
An is 25 years old
this went to stderr

Lời nhắc và kết quả thì đưa vào System.out, thông báo lỗi thì đưa vào System.err. Nhờ vậy người dùng có chuyển output của chương trình vào file thì vẫn thấy lỗi hiện trên màn hình.

Scanner đọc input như thế nào?

import java.util.Scanner;

Scanner sc = new Scanner(System.in);

Mọi thứ còn lại trong bài đều suy ra từ đúng mô hình này:

Một buffer, hai kiểu đọc: method token và nextLine

Scanner không nhìn thấy dòng, cũng không nhìn thấy phím bạn gõ. Nó nhìn thấy một luồng ký tự dài, và trả cho bạn từng mảnh của luồng đó theo hai kiểu khác nhau:

  1. Nhóm method đọc tokennext(), nextInt(), nextDouble() và các anh em. Token là một cụm ký tự nằm giữa hai delimiter, mà delimiter mặc định là mọi cụm whitespace: dấu cách, tab và dấu xuống dòng đều tính. Nhóm này bỏ qua whitespace ở đầu, đọc đúng một token, rồi dừng ngay sau ký tự cuối của token đó.
  2. Nhóm method đọc dòngnextLine(). Nó hoàn toàn không quan tâm tới token. Nó đọc mọi thứ từ vị trí hiện tại tới dấu xuống dòng gần nhất, trả về phần đó mà bỏ dấu xuống dòng, rồi nhảy qua dấu xuống dòng ấy.

Vì với nhóm đọc token thì whitespace chỉ là thứ để ngăn cách, cùng một chương trình sẽ nhận được input trình bày theo đủ kiểu khác nhau:

Scanner sc = new Scanner(System.in);
System.out.print("Three numbers: ");
int a = sc.nextInt();
int b = sc.nextInt();
int c = sc.nextInt();
System.out.printf("a=%d b=%d c=%d sum=%d%n", a, b, c, a + b + c);

Đưa vào 10 20 30 trên một dòng, hoặc 10, 20, 30 trên ba dòng, hoặc một mớ tab lẫn dòng trống, đều cho ra cùng một kết quả:

Three numbers: a=10 b=20 c=30 sum=60

Tiện thật, và đó cũng là gốc rễ của mọi vấn đề trong phần còn lại của bài. Method đọc token đọc xong con số rồi để dấu xuống dòng nằm lại trong luồng, bởi với nó dấu xuống dòng chỉ là thứ ngăn cách. Method đọc dòng sau đó gặp đúng cái dấu ngăn cách ấy và coi nó là cả một dòng.

Bảng method của Scanner và thứ mỗi method để lại trong buffer

MethodĐọc cái gìCon trỏ dừng ở đâu
next()một token ngăn bởi whitespace, trả về Stringngay sau token
nextInt()một token, parse thành intngay sau các chữ số
nextLong()một token, parse thành longngay sau các chữ số
nextDouble()một token, parse thành doublengay sau con số
nextBoolean()một token, true hoặc false, không phân biệt hoa thườngngay sau token
nextLine()phần còn lại của dòng hiện tại, bỏ dấu xuống dòngđầu dòng kế tiếp
hasNext()không đọc gì — chỉ báo còn token hay khôngkhông đổi
hasNextInt()không đọc gì — chỉ báo token kế tiếp có parse được thành int khôngkhông đổi
hasNextLine()không đọc gì — chỉ báo còn dòng nào khôngkhông đổi

Nửa quan trọng của bảng trên là nhóm hasNext*. Chúng không tiêu thụ gì cả; chúng nhìn trước một bước rồi trả về boolean. Đó là thứ làm cho việc validate khả thi, và cũng là thứ làm cho vòng lặp vô tận ở phần dưới khả thi.

Đây là một chương trình dò, in ra hành vi của con trỏ theo từng bước. Input gồm bốn dòng: 25, An Nguyen, Ha Noi Viet Nam, true 9007199254740993.

Scanner sc = new Scanner(System.in);

int n = sc.nextInt();
System.out.println("1. nextInt()   -> " + n);
System.out.println("   hasNextLine() = " + sc.hasNextLine());
System.out.println("2. nextLine()  -> [" + sc.nextLine() + "]   (the leftover newline)");
System.out.println("3. nextLine()  -> [" + sc.nextLine() + "]   (the real name)");
System.out.println("4. next()      -> [" + sc.next() + "]");
System.out.println("5. nextLine()  -> [" + sc.nextLine() + "]   (rest of that line)");
System.out.println("6. nextBoolean -> " + sc.nextBoolean());
System.out.println("7. nextLong    -> " + sc.nextLong());
System.out.println("8. hasNext()   = " + sc.hasNext());
1. nextInt()   -> 25
   hasNextLine() = true
2. nextLine()  -> []   (the leftover newline)
3. nextLine()  -> [An Nguyen]   (the real name)
4. next()      -> [Ha]
5. nextLine()  -> [ Noi Viet Nam]   (rest of that line)
6. nextBoolean -> true
7. nextLong    -> 9007199254740993
8. hasNext()   = false

Có ba điều đáng rút ra. Bước 2 trả về chuỗi rỗng, đó chính là cái bẫy. Bước 4 cho thấy next() chỉ đọc được Ha chứ không lấy trọn cái tên, vì dấu cách đã kết thúc token. Bước 5 cho thấy dấu cách nằm ở đầu chuỗi " Noi Viet Nam"nextLine() trả về phần còn lại của dòng đúng y nguyên, kể cả dấu cách vốn dùng để ngăn hai token.

Bẫy nextLine

Đây là cái bug đó. Nó ngốn của người mới học nhiều giờ hơn bất cứ thứ gì khác trong bài này.

Con trỏ của Scanner để lại dấu xuống dòng như thế nào

Tái hiện lại bug

import java.util.Scanner;

public class Broken {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);

        System.out.print("Age: ");
        int age = sc.nextInt();

        System.out.print("Name: ");
        String name = sc.nextLine();

        System.out.println("age=" + age);
        System.out.println("name=[" + name + "]");
        System.out.println("length=" + name.length());
    }
}

Chạy với hai dòng 25An Nguyen:

printf '25\nAn Nguyen\n' | java Broken
Age: Name: age=25
name=[]
length=0

Tuổi thì đúng. Tên là chuỗi rỗng độ dài 0, và chương trình còn chẳng thèm dừng lại chờ. Gõ trực tiếp bằng bàn phím thì hiệu ứng vẫn vậy mà còn khó hiểu hơn: lời nhắc Name: loé lên rồi chương trình kết thúc trước khi bạn kịp gõ.

Lý do đúng như mô hình ở trên. Luồng input là 25\nAn Nguyen\n. nextInt() đọc token 25 rồi dừng ngay sau chữ 5, để con trỏ nằm trước dấu xuống dòng. Sau đó nextLine() làm đúng phận sự: đọc từ con trỏ tới hết dòng hiện tại. Giữa con trỏ và chỗ kết thúc dòng đó chẳng còn gì, nên nó trả về "" rồi bước qua dấu xuống dòng. Cái tên thậm chí chưa được chạm tới.

Chú ý cách diễn đạt: nextLine() không hề hỏng và cũng không bỏ qua gì cả. Nó đã đọc một dòng — phần rỗng còn sót lại của dòng 1.

Cách 1: đọc nốt phần còn lại của dòng

Gọi nextLine() một lần, vứt kết quả đi, thế là con trỏ rơi vào đầu dòng thật:

System.out.print("Age: ");
int age = sc.nextInt();
sc.nextLine();                 // consume the rest of the number's line

System.out.print("Name: ");
String name = sc.nextLine();
Age: Name: age=25
name=[An Nguyen]

Cách này chạy được, và cũng là cách hầu hết tutorial chỉ. Cái giá là từ giờ bạn phải nhớ thêm một dòng sc.nextLine(); trơ trọi sau mỗi lần gọi method đọc token mà phía sau có đọc dòng. Sót một chỗ là bug quay lại, im lặng.

Cách 2: chỉ dùng nextLine rồi tự parse số

Đừng bao giờ trộn hai nhóm method. Đọc mọi thứ bằng nextLine(), rồi mới chuyển đổi:

import java.util.Scanner;

public class FixTwo {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);

        System.out.print("Age: ");
        int age = Integer.parseInt(sc.nextLine().trim());

        System.out.print("Name: ");
        String name = sc.nextLine();

        System.out.println("age=" + age);
        System.out.println("name=[" + name + "]");
    }
}
Age: Name: age=25
name=[An Nguyen]

Chỗ .trim() là quan trọng: người dùng lỡ gõ thừa một dấu cách ở cuối thì Integer.parseInt sẽ nhận chuỗi "25 ", và nó ném lỗi.

Nên chọn cách nào?

Cách 2, cho mọi chương trình đọc dữ liệu từ con người. Lý do:

  • Một mô hình thay vì hai. Mỗi lần đọc tiêu thụ đúng một dòng. Không phải nhớ trạng thái con trỏ giữa các dòng code nữa.
  • Không thể âm thầm hỏng lại. Cách 1 dựa vào một dòng code trơ trọi chỉ tồn tại vì tác dụng phụ của nó; lúc dọn code mà xoá nhầm là bug quay lại mà chẳng có gì báo.
  • Đọc được tên có dấu cách. sc.next() sẽ trả về An và bỏ lại Nguyen.
  • Lỗi rõ ràng hơn. Số sai định dạng cho bạn NumberFormatException ngay tại dòng bạn parse, chứ không phải một InputMismatchException từ đâu đó bên trong Scanner.

Cách 1 vẫn ổn cho đúng trường hợp mà Scanner sinh ra để phục vụ: input toàn số ngăn nhau bằng whitespace, nơi bạn vốn chẳng có ý định đọc một dòng chữ tự do nào.

Chuyện gì xảy ra khi input không phải là số?

nextInt() không trả về mã lỗi. Nó ném exception:

Vòng lặp guard hasNextInt, và vòng lặp vô tận khi không vứt token sai đi

hasNextInt() chỉ nhìn — quên tiêu thụ token sai là lặp vô tận:

Scanner sc = new Scanner(System.in);
System.out.print("Age: ");
int age = sc.nextInt();
System.out.println("age=" + age);
printf 'abc\n' | java BadInput
Age: Exception in thread "main" java.util.InputMismatchException
	at java.base/java.util.Scanner.throwFor(Scanner.java:947)
	at java.base/java.util.Scanner.next(Scanner.java:1602)
	at java.base/java.util.Scanner.nextInt(Scanner.java:2267)
	at java.base/java.util.Scanner.nextInt(Scanner.java:2221)
	at BadInput.main(BadInput.java:7)

java.util.InputMismatchException nghĩa là "có token đấy, nhưng nó không parse được thành thứ bạn yêu cầu". Đưa 3.5 vào nextInt() cũng ném đúng exception này. Sau đó token vẫn còn nguyên trong buffer — exception được ném ra trước khi Scanner kịp đi tiếp.

Exception là một chủ đề riêng và sẽ có bài sau nói cho tử tế. Ở đây, cứ coi try/catch và hasNextInt() là hai công cụ cho đúng một việc: không để input sai làm chết chương trình.

Vòng lặp validate

hasNextInt() cho biết token kế tiếp có parse được không, mà không tiêu thụ nó. Lặp chừng nào nó còn false, và phải vứt bỏ token sai bằng next() trước khi kiểm tra lại:

import java.util.Scanner;

public class Validate {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);

        System.out.print("Age: ");
        while (!sc.hasNextInt()) {
            String junk = sc.next();      // discard the offending token
            System.out.println(junk + " is not a whole number.");
            System.out.print("Age: ");
        }
        int age = sc.nextInt();

        System.out.println("age=" + age);
    }
}
printf 'abc\n3.5\n25\n' | java Validate
Age: abc is not a whole number.
Age: 3.5 is not a whole number.
Age: age=25

Vòng lặp vô tận khi quên discard token

Bỏ dòng sc.next() đi thì vòng lặp không còn gì để tiến tới:

System.out.print("Age: ");
while (!sc.hasNextInt()) {
    System.out.println("Not a number, try again.");
    // BUG: the bad token is still sitting in the buffer
}
System.out.println("age=" + sc.nextInt());
printf 'abc\n25\n' | java InfiniteLoop
Age: Not a number, try again.
Not a number, try again.
Not a number, try again.
Not a number, try again.
Not a number, try again.

Output trên bị cắt sau năm dòng; chương trình không tự dừng và phải kill nó. hasNextInt() nhìn vào abc, trả lời false, và không thay đổi gì hết. Vòng lặp kế tiếp lại nhìn đúng cái abc đó. Nếu chương trình của bạn từng xối một thông báo validate ra kín màn hình thì lý do là đây: phải có thứ gì đó trong vòng lặp tiêu thụ cái input vừa trượt kiểm tra.

NoSuchElementException: input đã hết

Exception thứ hai mà người mới hay gặp là khi không còn token nào nữa — bạn pipe một file đã hết, hoặc bấm Ctrl-D (Ctrl-Z rồi Enter trên Windows) ngay tại lời nhắc:

printf '' | java BadInput
Age: Exception in thread "main" java.util.NoSuchElementException
	at java.base/java.util.Scanner.throwFor(Scanner.java:945)
	at java.base/java.util.Scanner.next(Scanner.java:1602)
	at java.base/java.util.Scanner.nextInt(Scanner.java:2267)
	at java.base/java.util.Scanner.nextInt(Scanner.java:2221)
	at BadInput.main(BadInput.java:7)

Gọi nextLine() khi input đã hết thì ném đúng class đó kèm một thông báo: NoSuchElementException: No line found.

Hai exception này nhìn giống nhau nhưng nghĩa ngược nhau. InputMismatchException: có input, nhưng sai dạng. NoSuchElementException: không còn input nào. Chặn cái thứ hai bằng hasNext() hoặc hasNextLine() trước khi đọc, đó là cách duy nhất đáng tin để viết chương trình sống sót khi bị chạy với input lấy từ file.

Bẫy locale: 3.14 hay 3,14?

Phần parse input và phần format output được điều khiển riêng, nên mỗi bên cần một cách sửa riêng.

Hai lane độc lập: useLocale sửa nextDouble, còn printf vẫn cần Locale của riêng nó

nextDouble() parse theo locale mặc định của JVM, mà một nửa thế giới viết số thập phân bằng dấu phẩy. Chỗ này đáng nắm chính xác, vì cùng một file source lại chạy khác nhau trên hai máy:

Scanner sc = new Scanner(System.in);
System.out.println("default locale: " + Locale.getDefault());
System.out.print("Price: ");
double price = sc.nextDouble();
System.out.println("price=" + price);

Dưới locale tiếng Đức, chuỗi 3.14 không phải là một con số:

printf '3.14\n' | java -Duser.language=de -Duser.country=DE LocaleDemo
default locale: de_DE
Price: Exception in thread "main" java.util.InputMismatchException
	at java.base/java.util.Scanner.throwFor(Scanner.java:947)
	at java.base/java.util.Scanner.next(Scanner.java:1602)
	at java.base/java.util.Scanner.nextDouble(Scanner.java:2573)
	at LocaleDemo.main(LocaleDemo.java:9)

Còn 3,14 thì có:

printf '3,14\n' | java -Duser.language=de -Duser.country=DE LocaleDemo
default locale: de_DE
Price: price=3.14

Locale tiếng Việt cũng y như vậy — vi_VN cũng dùng dấu phẩy làm dấu thập phân, nên một chương trình chạy ngon trên máy để tiếng Anh sẽ hỏng trên máy để tiếng Việt và ngược lại. Ghim locale ngay trên Scanner thì định dạng input thôi phụ thuộc vào máy:

Scanner sc = new Scanner(System.in);
sc.useLocale(Locale.US);            // "3.14" now parses everywhere
printf '3.14\n' | java -Duser.language=vi -Duser.country=VN LocaleFix
default locale: vi_VN
Price: price=3,14

Hãy đọc kỹ output đó, vì nó lộ ra đúng nửa vấn đề mà hầu hết bài viết bỏ qua. Phần đọc input đã được sửa — 3.14 đã được chấp nhận dưới vi_VN. Nhưng chương trình lại in ra 3,14, bởi printf cũng định dạng theo locale mặc định, còn useLocale chỉ tác động lên Scanner. FormatterScanner là hai object riêng với hai locale riêng:

System.out.printf("default : %.2f | %,d%n", 3.14159, 1234567);
System.out.printf(Locale.US, "US      : %.2f | %,d%n", 3.14159, 1234567);
java -Duser.language=de -Duser.country=DE PrintfLocale
default locale: de_DE
default : 3,14 | 1.234.567
US      : 3.14 | 1,234,567

Vậy nên: scanner.useLocale(Locale.US) cho input, và System.out.printf(Locale.US, ...) cho output mỗi khi các chữ số phải chính xác. Ngược lại, nếu bạn muốn theo đúng thói quen của người dùng ở mọi chỗ thì đừng đổi gì cả, cứ để locale mặc định làm việc của nó — sai lầm nằm ở chỗ tưởng rằng chẳng có locale nào dính vào đây.

Đọc nhiều giá trị trên một dòng và useDelimiter

Nhiều token trên cùng một dòng thì không cần xử lý gì đặc biệt, vì whitespace vốn đã là delimiter. Mọi ký tự khác thì cần. useDelimiter thay cái pattern whitespace bằng một regular expression của bạn — ở đây là dấu phẩy, áp lên một Scanner đọc một chuỗi văn bản:

Scanner line = new Scanner(System.in);
String csv = line.nextLine();

Scanner fields = new Scanner(csv);
fields.useDelimiter(",");
while (fields.hasNext()) {
    System.out.println("[" + fields.next() + "]");
}
fields.close();
printf 'An Nguyen,25,1250000\n' | java DelimiterDemo
[An Nguyen]
[25]
[1250000]

Để ý mẹo trong đoạn code đó: đọc dòng bằng nextLine(), rồi dựng một Scanner thứ hai trên chuỗi thu được. Một Scanner có thể bọc chuỗi bất kỳ chứ không riêng gì System.in, và làm theo cách này thì ranh giới giữa các dòng vẫn còn nguyên.

Nếu bạn gọi thẳng useDelimiter(",") trên Scanner của System.in, dấu xuống dòng thôi làm delimiter và trở thành một ký tự bình thường nằm trong token cuối:

Scanner sc = new Scanner(System.in);
sc.useDelimiter(",");                 // the newline is now just a character
while (sc.hasNext()) {
    String t = sc.next();
    System.out.println("[" + t + "] length=" + t.length());
}
printf 'a,b,c\n' | java DelimiterTrap
[a] length=1
[b] length=1
[c
] length=2

Token cuối là c cộng một line feed, độ dài 2. Hãy viết pattern nhận cả hai loại ngăn cách:

sc.useDelimiter("\\s*,\\s*|\\R");     // comma with optional spaces, or a line break
printf 'a, b ,c\nd,e\n' | java DelimiterFix
[a] length=1
[b] length=1
[c] length=1
[d] length=1
[e] length=1

\R là ký hiệu regex cho mọi kiểu xuống dòng, nên nó xử lý được cả file Unix lẫn file Windows.

Đóng Scanner là đóng luôn System.in

Scanner có implement Closeable, nên try-with-resources dùng được và tự đóng giúp bạn:

try (Scanner sc = new Scanner(System.in)) {
    System.out.print("Name: ");
    String name = sc.nextLine();
    System.out.println("hello " + name);
}

Còn đây là chỗ tinh tế mà không ai nhắc. Đóng một Scanner là đóng luôn cái stream nằm dưới nó, mà stream nằm dưới chính là System.in — một object dùng chung cho cả JVM. Đã đóng rồi thì nó đóng luôn tới hết chương trình:

Scanner first = new Scanner(System.in);
System.out.println("first: " + first.nextLine());
first.close();

Scanner second = new Scanner(System.in);   // System.in is already closed
System.out.println("second: " + second.nextLine());
printf 'hello\nworld\n' | java CloseDemo
first: hello
Exception in thread "main" java.util.NoSuchElementException: No line found
	at java.base/java.util.Scanner.nextLine(Scanner.java:1660)
	at CloseDemo.main(CloseDemo.java:10)

Scanner thứ hai là một object hoàn toàn hợp lệ đang đọc một stream đã chết. Bản try-with-resources cũng y hệt — sau khi ra khỏi block, gọi hasNextLine() trên một Scanner mới toanh trả về false thay vì đứng chờ input.

⚠️ Quy tắc thực dụng: mỗi chương trình chỉ tạo đúng một Scanner trên System.in, và đừng đóng nó. Dù sao thì JVM cũng đóng System.in lúc thoát. Nếu linter kêu ca về resource chưa đóng thì đóng nó đúng một lần ở cuối main — tuyệt đối không đóng bên trong một method phụ.

Quy tắc này khác đi với Scanner bọc một String, hay bọc một file: những cái đó thật sự giữ một resource đáng đóng, và try-with-resources là đúng cho chúng.

Scanner vs BufferedReader vs System.console()

ScannerBufferedReaderSystem.console()
Khởi tạonew Scanner(System.in)new BufferedReader(new InputStreamReader(System.in))System.console()
Parse sẵncó: nextInt, nextDoublekhông — bạn tự gọi Integer.parseIntkhông
Đọc một dòngnextLine()readLine()readLine()
Khi hết inputném NoSuchElementExceptiontrả về nulltrả về null
Số sai định dạngInputMismatchException (unchecked)NumberFormatException tại chỗ bạn parsetuỳ cách bạn parse
Checked exceptionkhông córeadLine() ném IOExceptionkhông có
Phụ thuộc localecó, nextDouble và các anh emkhông, bạn tự kiểm soátkhông
Chạy trong IDE hoặc input từ pipechạy đượcchạy đượctrả về null
Tốc độthấp nhấtcaokhông dành cho input lớn
ThêmuseDelimiter, hasNextIntchỉ có dòngreadPassword() giấu ký tự gõ vào

BufferedReader viết dài hơn vài chữ lúc khởi tạo nhưng cho bạn mô hình đơn giản hơn hẳn — một method, một dòng, hết thì null:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class ReaderDemo {
    public static void main(String[] args) throws IOException {
        BufferedReader in = new BufferedReader(new InputStreamReader(System.in));

        System.out.print("Age: ");
        int age = Integer.parseInt(in.readLine().trim());

        System.out.print("Name: ");
        String name = in.readLine();

        System.out.println("age=" + age);
        System.out.println("name=[" + name + "]");
    }
}
printf '25\nAn Nguyen\n' | java ReaderDemo
Age: Name: age=25
name=[An Nguyen]

Ở đây không có bẫy nextLine, vì chỉ có đúng một cách đọc: nguyên dòng. Hết input thì readLine() trả về null chứ không ném exception, nên while ((line = in.readLine()) != null) là vòng lặp chuẩn.

System.console() là trường hợp lạ. Nó cho bạn readPassword(), đọc mà không hiện ký tự lên màn hình, nhưng chỉ có mặt khi chương trình gắn với một terminal thật. Input từ pipe, input chuyển hướng từ file, và phần lớn cửa sổ run trong IDE đều cho bạn null:

printf 'An\nsecret\n' | java ConsoleDemo
System.console() = null
no console: input is redirected, or running under an IDE

Nên System.console() dành cho công cụ dòng lệnh cần hỏi mật khẩu, và không bao giờ dành cho chương trình mà bạn muốn test bằng file input chuyển hướng.

Về tốc độ: Scanner chậm, và chuyện đó chỉ thành vấn đề khi input lớn. Cộng một triệu số nguyên lấy từ file qua pipe, lấy lần nhanh nhất trong năm lần chạy sau khi đã warm up, trên máy này:

Cách làmThời gian tốt nhất
Scanner.hasNextInt() / nextInt()309 ms
BufferedReader.readLine() + Integer.parseInt48 ms

Nhanh hơn khoảng sáu lần. Con số chỉ mang tính tham khảo và sẽ khác trên phần cứng của bạn, nhưng tỉ lệ đó là lý do dân competitive programming không dùng Scanner. Còn với chương trình đọc ba giá trị từ một con người thì khác biệt này chỉ là sai số, và sự tiện lợi của Scanner thắng.

Ví dụ hoàn chỉnh: hỏi, validate và in ra hoá đơn

Tất cả những gì ở trên gộp vào một chương trình: đọc theo dòng, validate có cho nhập lại, chặn trường hợp input hết giữa chừng, và output định dạng có ghim locale.

import java.util.Locale;
import java.util.Scanner;

public class Receipt {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);

        String name = ask(sc, "Name: ");
        while (name.isEmpty()) {
            System.out.println("Name cannot be empty.");
            name = ask(sc, "Name: ");
        }

        int age;
        while (true) {
            try {
                age = Integer.parseInt(ask(sc, "Age: "));
                if (age > 0 && age < 130) break;
                System.out.println("Age must be between 1 and 129.");
            } catch (NumberFormatException e) {
                System.out.println("Age must be a whole number.");
            }
        }

        double price;
        while (true) {
            try {
                price = Double.parseDouble(ask(sc, "Price: "));
                if (price >= 0) break;
                System.out.println("Price cannot be negative.");
            } catch (NumberFormatException e) {
                System.out.println("Price must be a number, e.g. 12.50");
            }
        }

        System.out.println();
        System.out.printf(Locale.US, "%-12s %3s %12s%n", "CUSTOMER", "AGE", "PRICE");
        System.out.printf(Locale.US, "%-12s %3d %,12.2f%n", name, age, price);
    }

    private static String ask(Scanner sc, String prompt) {
        System.out.print(prompt);
        if (!sc.hasNextLine()) {
            System.out.println();
            System.out.println("Input ended unexpectedly.");
            System.exit(1);
        }
        return sc.nextLine().trim();
    }
}

Chạy với bộ input sai ba lần trước khi đúng:

printf 'An Nguyen\ntwenty\n25\nabc\n-5\n1250000.5\n' | java Receipt
Name: Age: Age must be a whole number.
Age: Price: Price must be a number, e.g. 12.50
Price: Price cannot be negative.
Price: 
CUSTOMER     AGE        PRICE
An Nguyen     25 1,250,000.50

Các giá trị nhập vào không xuất hiện trong đoạn ghi lại đó vì input đến từ pipe chứ không phải bàn phím, nên chẳng có gì hiện chúng lên. Cái bạn thấy là output của chính chương trình: các lời nhắc, ba lần từ chối, và hai dòng cuối được canh cột bởi %-12s %3d %,12.2f.

Còn khi input hết giữa chừng, cái chốt hasNextLine() trong ask cho ra một thông báo thay vì một stack trace:

printf 'An Nguyen\n' | java Receipt
Name: Age: 
Input ended unexpectedly.

FAQ

Vì sao chương trình bỏ qua chỗ nhập tên?

Nó không bỏ qua đâu. Một lệnh nextInt(), nextDouble() hay next() trước đó đã để dấu xuống dòng nằm lại trong buffer, và nextLine() của bạn đã đọc đúng cái phần rỗng còn sót của dòng cũ. Hoặc thêm một dòng sc.nextLine(); trơ trọi sau lệnh đọc token, hoặc đọc mọi thứ bằng nextLine() rồi parse số bằng Integer.parseInt.

Có thể tạo hai Scanner trên System.in không?

Được, và chúng sẽ giành nhau cùng một stream: mỗi cái đều buffer trước, nên phần input mà cái này đã đọc sẽ biến mất trước khi cái kia nhìn thấy. Thêm nữa, chỉ cần một cái bị đóng là System.in đóng với cả hai. Hãy tạo một Scanner rồi truyền nó cho các method cần dùng.

Vì sao nextDouble() báo lỗi với 3.14 trên máy tôi?

Vì locale mặc định của JVM trên máy bạn dùng dấu phẩy làm dấu thập phân — vi_VNde_DE đều vậy. Gọi sc.useLocale(Locale.US) ngay sau khi tạo Scanner nếu bạn muốn 3.14 parse được bất kể máy nào, và nhớ rằng printf định dạng theo locale mặc định trừ khi bạn truyền Locale.US làm argument đầu tiên.

Làm sao đọc tới khi người dùng dừng?

Lặp theo một method hasNext* thay vì theo số lượng: while (sc.hasNextInt()) cho số, hoặc while (sc.hasNextLine()) cho dòng. Cả hai trả về false khi hết input, tức là Ctrl-D trên Linux và macOS, Ctrl-Z rồi Enter trên Windows, hoặc đơn giản là hết file được chuyển hướng vào. Đọc quá điểm đó chính là chỗ ném NoSuchElementException.

Scanner có đủ nhanh cho competitive programming không?

Không. Trong phép đo ở trên nó mất 309 ms để cộng một triệu số nguyên trong khi BufferedReader chỉ mất 48 ms, mà các bài có input lớn thường được thiết kế với đúng khoảng cách đó trong đầu. Với những bài như vậy hãy dùng BufferedReader và tự parse. Còn với chương trình thường ngày đọc vài giá trị từ một con người thì Scanner là công cụ đúng.

Kết luận

Toàn bộ tính cách của Scanner đến từ một quyết định thiết kế: nhóm method đọc token và nhóm method đọc dòng cùng đọc một luồng theo hai kiểu không tương thích nhau. Khi bạn hình dung được con trỏ dừng ngay trước dấu xuống dòng thì chuỗi rỗng, vòng lặp vô tận và NoSuchElementException đều thôi là bất ngờ. Hãy đọc nguyên dòng rồi tự parse, chặn bằng hasNextLine(), ghim locale khi các chữ số phải chính xác, và đừng đóng Scanner bọc System.in.

Mọi chương trình trong bài này đều chạy một mạch từ trên xuống dưới. Bài tiếp theo giới thiệu chỗ chương trình bắt đầu biết rẽ nhánh: conditional statement — if, else if, elseswitch.

Bài viết liên quan

[Java Basics] Ép kiểu dữ liệu trong Java: implicit và explicit

Ép kiểu dữ liệu trong Java: widening và narrowing, tại sao (byte) 300 in ra 44, tại sao byte cộng byte không compile được, cast ẩn bên trong += và vì sao cast không phải là parsing.

[Java Basics] Biến và kiểu dữ liệu trong Java: primitive vs reference

Hướng dẫn đầy đủ về biến và kiểu dữ liệu trong Java - 8 kiểu primitive cùng range thật, reference type, stack vs heap, literal, integer overflow, var, final và autoboxing, mọi ví dụ đều được compile và chạy trên JDK 21.

[Java Basics] Phương thức trong Java: khai báo và gọi method

Cách khai báo và gọi method trong Java: các thành phần của một declaration, static method so với instance method và lỗi non-static method cannot be referenced from a static context, return statement, call stack và StackOverflowError, cách đọc stack trace, Javadoc, cùng những lỗi compiler thật mà người mới hay gặp.

[Java Basics] Tính kế thừa trong Java: extends và super

extends hoạt động thế nào, subclass kế thừa gì và không kế thừa gì, vì sao constructor không bao giờ được kế thừa, super(...) xâu chuỗi constructor lên tới java.lang.Object rồi chạy ngược xuống ra sao, field hiding khác overriding chỗ nào, protected qua package, final class, vấn đề fragile base class, và khi nào composition là lựa chọn đúng.