Tài liệu này tổng hợp các câu hỏi phỏng vấn về multithreading và concurrency trong Java, tập trung vào những chủ đề trọng tâm thường được các nhà tuyển dụng quan tâm sâu sắc. Chúng sẽ giúp bạn ôn tập kĩ càng cũng như thể hiện khả năng của mình trong việc xây dựng các hệ thống xử lý song song an toàn và hiệu quả khi.Các câu hỏi phỏng vấn về Multithreading và Concurrency trong Java
Các câu hỏi phỏng vấn về multithreading trong Java
Sự khác biệt giữa process và thread là gì?
Một process (tiến trình) là một môi trường thực thi khép kín và có thể được xem như một chương trình hoặc ứng dụng. Trong khi đó thread (luồng) chỉ là một tác vụ thực thi đơn lẻ bên trong process đó.
Môi trường runtime của Java (JRE) hoạt động như một process duy nhất, và các chương trình Java sẽ chạy bên trong process này. Thread có thể được coi là một process hạng nhẹ. Việc tạo một thread đòi hỏi ít tài nguyên hơn, các thread tồn tại bên trong process và chia sẻ các tài nguyên của process đó.
Lợi ích của lập trình đa luồng là gì?
Việc có nhiều thread đồng thời giúp cải thiện hiệu năng chương trình. Kĩ thuật này tránh trường hợp lãng phí thời gian CPU chờ đợi việc một thread nào đó đang phải đợi để có tài nguyên.
Các thread chia sẻ chung vùng nhớ heap. Do đó việc tạo nhiều thread để thực thi một tác vụ thường hiệu quả hơn so với việc tạo nhiều process. Ví dụ, Servlet có hiệu năng tốt hơn CGI vì Servlet hỗ trợ multithreading, trong khi CGI thì không.
Sự khác biệt giữa user thread và daemon thread là gì?
Khi ta tạo một thread trong chương trình Java, nó được gọi là user thread. Một daemon thread thì chạy ở chế độ nền (background) và không ngăn cản JVM dừng hoạt động. Khi không còn user thread nào chạy, JVM sẽ dừng chương trình. Một thread con (child thread) được tạo từ một daemon thread cũng sẽ là một daemon thread.
Làm thế nào để tạo một thread trong Java?
Có hai cách để tạo thread trong Java: cách thứ nhất là triển khai interface Runnable
, sau đó tạo một đối tượng Thread
từ instance của Runnable
đó. Cách thứ hai là kế thừa class Thread
.
Các trạng thái khác nhau trong vòng đời của thread là gì?
Khi một thread được tạo trong chương trình Java, nó ở trạng thái New. Khi ta gọi phương thức start()
trên thread, trạng thái của nó chuyển thành Runnable.
Trình điều phối (scheduler) có nhiệm vụ cấp phát tài nguyên CPU cho các thread đang ở trạng thái Runnable và chuyển trạng thái của chúng sang Running. Thread cũng có các trạng thái khác bao gồm Waiting, Blocked và Dead.
Chúng ta có thể gọi phương thức run() của một lớp thread không?
Ta hoàn toàn có thể gọi trực tiếp phương thức run()
của một lớp thread. Tuy nhiên, khi đó nó sẽ được chạy như một phương thức thông thường. Để nó trên một thread riêng biệt, ta cần gọi phương thức Thread.start()
.
Làm thế nào để tạm dừng việc thực thi của một thread trong một khoảng thời gian cụ thể?
Ta có thể sử dụng phương thức sleep()
của class Thread
để tạm dừng việc thực thi của một thread trong một khoảng thời gian nhất định. Cần lưu ý rằng sleep()
chỉ tạm dừng thread. Sau khi hết thời gian ấn định, trạng thái của thread sẽ chuyển thành Runnable và nó sẽ được thực thi trở lại tùy theo cơ chế điều phối thread.
Hãy giải thích độ ưu tiên của thread
Mỗi thread đều có một mức độ ưu tiên (priority). Thông thường, thread có độ ưu tiên cao hơn sẽ được ưu tiên thực thi trước. Nhưng điều này còn phụ thuộc vào cách triển khai của thread scheduler, vốn lại phụ thuộc vào hệ điều hành (OS).
Chúng ta có thể chỉ định độ ưu tiên cho thread, nhưng không có gì đảm bảo rằng thread có độ ưu tiên cao sẽ luôn được thực thi trước thread có độ ưu tiên thấp. Độ ưu tiên của thread là một giá trị kiểu int
nằm trong khoảng từ 1 (thấp nhất) đến 10 (cao nhất).
Thread scheduler và time slicing là gì?
Thread scheduler (trình điều phối luồng) là một dịch vụ (service) của hệ điều hành (OS) chịu trách nhiệm phân bổ thời gian CPU cho các thread đang ở trạng thái Runnable. Sau khi một thread được tạo và khởi chạy, thứ tự thực thi của nó phụ thuộc vào cách triển khai của thread scheduler.
Time slicing (phân chia thời gian) là quá trình phân chia thời gian CPU hiện có cho các thread đang ở trạng thái Runnable. Việc phân bổ thời gian CPU cho các thread có thể dựa trên độ ưu tiên của thread, hoặc thread đã chờ đợi lâu hơn có thể được ưu tiên cấp CPU trước.
Java không kiểm soát trực tiếp việc điều phối thread, do đó tốt nhất ta nên quản lý việc này từ bên trong ứng dụng.
Context-switching trong lập trình đa luồng là gì?
Context switching (chuyển đổi ngữ cảnh) là quá trình lưu trữ trạng thái hiện tại của CPU và sau đó khôi phục lại trạng thái đó để cho phép một thread tạm dừng thực thi và sau đó chạy tiếp lại từ đúng vị trí đã dừng. Đây là một tính năng cơ bản của các hệ điều hành đa nhiệm và là nền tảng để hỗ trợ các môi trường đa luồng.
Làm thế nào để đảm bảo main() là thread kết thúc cuối cùng trong một chương trình Java?
Chúng ta có thể sử dụng phương thức join()
của class Thread
. Bằng cách gọi thread.join()
trên các thread khác, thread main
sẽ đợi cho đến khi tất cả các thread đó chạy xong khi nó kết thúc.
Các thread giao tiếp với nhau như thế nào?
Khi các thread cùng chia sẻ tài nguyên, chúng cần giao tiếp với nhau để phối hợp hoạt động. Các phương thức wait()
, notify()
và notifyAll()
của class Object
cho phép các thread trao đổi thông tin về trạng thái lock (khóa) của một tài nguyên dùng chung.
Lưu ý: bạn có thể tìm đọc bài viết của chúng tôi về wait()
, notify()
và notifyAll()
để hiểu thêm về vấn đề này.
Tại sao các phương thức giao tiếp thread wait(), notify() và notifyAll() lại nằm trong lớp Object?
Mỗi Object
trong Java đều gắn liền với một monitor (bộ giám sát). Các phương thức wait()
, notify()
và notifyAll()
được dùng để chờ trên monitor của Object
đó, hoặc để thông báo cho các thread đang chờ rằng monitor của Object
đó đã được giải phóng.
Bản thân các Thread
trong Java không có monitor riêng, và cơ chế synchronization (đồng bộ hóa) hoạt động dựa trên monitor của Object
. Do đó, các phương thức này được đặt trong class Object
để bất kỳ đối tượng nào cũng có thể tham gia vào việc giao tiếp và đồng bộ hóa giữa các thread.
Tại sao các phương thức wait(), notify() và notifyAll() phải được gọi từ phương thức hoặc khối đồng bộ (synchronized)?
Khi một thread gọi phương thức wait()
trên một Object
, nó phải giữ monitor của Object
đó. Thread sẽ tạm thời nhả monitor này và chuyển vào trạng thái chờ cho đến khi một thread khác gọi notify()
trên cùng Object
đó.
Tương tự, khi một thread gọi notify()
trên một Object
, nó giải phóng monitor của Object
đó và khác thread khác có thể chiếm lấy monitor này.
Vì tất cả các phương thức này (wait()
, notify()
, notifyAll()
) đều liên quan đến việc quản lý monitor của Object
, mà việc chiếm giữ monitor chỉ có thể được thực hiện thông qua phương thức hoặc khối đồng bộ, nên ta bắt buộc phải gọi chúng bên trong các ngữ cảnh synchronized đó.
Tại sao các phương thức sleep() và yield() của thread lại là static?
Các phương thức sleep()
và yield()
luôn tác động lên thread đang chạy tại thời điểm đó. Sẽ vô nghĩa nếu ta gọi chúng để tác động lên một thread nào khác đang ở trạng thái chờ.
Việc khai báo chúng là static
đảm bảo rằng các phương thức này luôn ảnh hưởng đến thread hiện hành, tránh việc lầm tưởng là ta có thể dùng nó để gọi tới một thread đang không chạy nào đó.
Làm thế nào để đạt tính an toàn luồng trong Java?
Có nhiều cách để đạt được thread safety (an toàn luồng) trong Java, bao gồm: sử dụng cơ chế synchronization, các lớp atomic đồng thời, triển khai interfaceLock
đồng thời, sử dụng từ khóa volatile
, sử dụng các immutable class (lớp bất biến), và các class được thiết kế sẵn để đảm bảo an toàn luồng.
Từ khóa volatile trong Java để làm gì?
Khi ta sử dụng từ khóa volatile
với một biến, các thread sẽ đọc giá trị của biến đó trực tiếp từ bộ nhớ chính (main memory) và không sử dụng giá trị đã được cache. Nó đảm bảo rằng giá trị mà một thread đọc được luôn là giá trị mới nhất có trong bộ nhớ.
Ta nên dùng cách nào hơn, phương thức hay khối đồng bộ?
Nên ưu tiên khối đồng bộ hơn vì nó không khóa Object. Ngược lại, phương thức đồng bộ sẽ khóa đối tượng. Và nếu có nhiều khối đồng bộ trong class, nó sẽ ngăn chúng thực thi và đưa chúng vào trạng thái chờ để lấy lock trên Object (kể cả khi chúng không liên quan đến nhau).
Làm thế nào để tạo daemon thread trong Java?
Ta có thể sử dụng phương thức setDaemon(true)
để tạo một daemon thread trong Java. Cần lưu ý là phải gọi phương thức này trước khi gọi phương thức start()
của thread, nếu không sẽ xảy ra lỗi IllegalThreadStateException
.
ThreadLocal là gì?
ThreadLocal được sử dụng để tạo các biến mà mỗi thread sẽ có một bản sao độc lập riêng (biến thread-local). Thông thường, các biến của một object được chia sẻ giữa tất cả các thread truy cập object đó. Nếu việc chia sẻ này gây ra vấn đề về an toàn luồng và ta muốn tránh sử dụng synchronization, ta có thể sử dụng ThreadLocal.
Mỗi thread truy cập vào một biến ThreadLocal sẽ được cho một bản sao của riêng nó thông qua các phương thức get()
và set()
. Giá trị này có tính cục bộ đối với thread đó và không ảnh hưởng đến các thread khác. Các biến này thường được khai báo với các trường private static
trong các class nào cần liên kết với trạng thái của một thread.
ThreadGroup là gì? Tại sao ta lại không nên sử dụng nó?
ThreadGroup là một class được thiết kế để quản lý một nhóm các thread và cung cấp thông tin về chúng. Tuy nhiên, API của ThreadGroup được coi là yếu và không cung cấp nhiều chức năng hữu ích mà class Thread
không có.
Hai tính năng chính của nó là lấy danh sách các thread đang hoạt động trong nhóm và thiết lập một trình xử lý chung cho các lỗi không được bắt cho các thread trong nhóm. Nhưng từ Java 1.5, class Thread
đã có phương thức setUncaughtExceptionHandler(UncaughtExceptionHandler eh)
để xử lý các lỗi không được bắt cho thread. Do đó, ThreadGroup
gần như đã trở nên lỗi thời và không còn được khuyến khích sử dụng.
t1.setUncaughtExceptionHandler(new UncaughtExceptionHandler(){
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.println("exception occured:"+e.getMessage());
}
});
Thread dump là gì, Làm thế nào để lấy thread dump của một chương trình?
Thread dump là một danh sách tất cả các thread đang hoạt động trong JVM tại một thời điểm nhất định. Nó có ích trong việc chẩn đoán các vấn đề về hiệu năng, chẳng hạn như bottleneck (điểm nghẽn cổ chai) và các tình huống deadlock.
Có nhiều cách để tạo thread dump, ví dụ như sử dụng các công cụ Profiler, lệnh kill -3
(trên Unix/Linux), hoặc công cụ dòng lệnh jstack
đi kèm với JDK.
Nhiều lập trình viên ưa chuộng jstack
vì nó dễ sử dụng và có sẵn trong bộ JDK. Ta có thể dùng nó trong script để tự động ghi lại thread dump tại các thời điểm ấn định để phục vụ cho việc phân tích sau này.
Deadlock là gì? Làm thế nào để phân tích và tránh deadlock?
Deadlock là trường hợp hai hoặc nhiều thread bị khóa (block) vô thời hạn. Nó xảy ra khi có ít nhất hai thread và hai hoặc nhiều hơn resource.
Để phân tích deadlock, ta cần xem xét thread dump của ứng dụng. Trong thread dump, ta cần tìm các thread có trạng thái BLOCKED
và xem chúng đang chờ tài nguyên nào. Mỗi tài nguyên thường có một ID định danh giúp ta xác định được thread nào đang giữ lock của đối tượng đó.
Các biện pháp phổ biến để tránh deadlock bao gồm: tránh sử dụng nested lock (khóa lồng nhau) không cần thiết, chỉ lock những tài nguyên thực sự cần và trong thời gian ngắn nhất có thể (lock granularity), và tránh việc chờ đợi tài nguyên vô thời hạn (ví dụ, sử dụng timeout khi cố gắng lấy lock).
Lớp Timer là gì? Làm thế nào để lên lịch một tác vụ chạy sau một khoảng thời gian cụ thể?
java.util.Timer
là một lớp tiện ích hỗ trợ (utility) dùng để lên lịch cho các tác vụ (task) để được thực thi bởi một thread chạy nền vào một thời điểm nhất định trong tương lai. Class Timer
cho phép lên lịch một tác vụ để chạy một lần hoặc chạy lặp đi lặp lại theo các khoảng thời gian đều đặn.
java.util.TimerTask
là một abstract class (lớp trừu tượng) triển khai interface Runnable
. Ta cần kế thừa từ class này để định nghĩa tác vụ của riêng mình, sau đó tác vụ đó có thể được lên lịch bằng cách sử dụng một instance của class Timer
.
Thread pool là gì? Làm thế nào để tạo Thread Pool trong Java?
Thread pool quản lý một nhóm các worker thread (luồng công việc) với một hàng đợi (queue) chứa các tác vụ (task) đang chờ được thực thi. Nó bao gồm một tập hợp cá thread Runnable
và các worker thread sẽ chạy các Runnable ở trong queue.
java.util.concurrent.Executors
cung cấp triển khai của interface java.util.concurrent.Executor
để tạo thread pool trong Java.
Điều gì sẽ xảy ra nếu chúng ta không ghi đè phương thức run() của lớp Thread?
Hãy xem qua một ví dụ của phương thức run()
trong class Thread
.
public void run() {
if (target != null) {
target.run();
}
}
Đối tượng target ở trên được đặt trong phương thức init() của lớp Thread. Nếu chúng ta tạo một instance của lớp Thread với new TestThread()
, nó được đặt thành null. Vậy nên sẽ không có gì xảy ra nếu chúng ta không ghi đè phương thức run()
.
Dưới đây là một ví dụ đơn giản minh họa điều này:
public void run() {
if (target != null) {
target.run();
}
}
Nó sẽ in ra kết quả sau rồi dừng:
Before starting thread
After starting thread
Các câu hỏi phỏng vấn về concurrency trong Java
Atomic operation là gì? Các lớp atomic trong Java Concurrency API là gì?
Atomic operation (thao tác nguyên tử) là một thao tác mang tính đơn vị công việc, không thể chia nhỏ và không bị gián đoạn hay can thiệp bởi các thread khác. Môi trường đa luồng rất cần atomic operation để đảm bảo tính nhất quán của dữ liệu.
Ví dụ: phép toán i++
không phải là một atomic operation vì nó bao gồm ba bước riêng biệt: đọc giá trị hiện tại của i
, tăng giá trị đó, và ghi lại giá trị mới. Nếu nhiều thread cùng thực hiện i++
đồng thời, có thể xảy ra trường hợp một thread đọc giá trị cũ của i
ngay sau khi một thread khác đã đọc nhưng chưa kịp ghi lại giá trị mới, dẫn đến xung đột và gây ra kết quả sai.
Để đảm bảo tính nguyên tử cho các thao tác tăng giá trị như vậy, ta có thể sử dụng synchronization. Tuy nhiên, từ Java 5 package java.util.concurrent.atomic
đã có các lơp wrapper cho int và long để thực hiện các phép toán phổ biến một cách nguyên tử mà không cần đến synchronization.
Interface Lock trong Java Concurrency API là gì? Những thế mạnh của nó so với synchronization?
Interface này cung cấp các cơ chế khóa mạnh mẽ hơn so với việc sử dụng phương thức và câu lệnh đồng bộ truyền thống. Nó cho phép cấu trúc code linh hoạt hơn, có thể có các đặc tính khác nhau, và hỗ trợ nhiều đối tượng Condition
liên kết với một Lock
.
Một số ưu điểm chính của Lock
so synchronization với bao gồm:
- Có thể cấu hình để hoạt động ở chế độ công bằng.
- Khả năng bị gián đoạn khi chờ lock.
- Khả năng thử lấy lock, nhưng trả về ngay lập tức (hoặc sau timeout) nếu không lấy được .
- Linh hoạt trong việc lấy và giải phóng lock trong các phạm vi và thứ tự khác nhau.
Executors Framework là gì?
Executor Framework được thêm vào Java 5, với interface trung tâm là java.util.concurrent.Executor
. Đây là một framework được thiết kế để chuẩn hóa việc khởi tạo, điều phối, thực thi và kiểm soát các tác vụ bất đồng bộ (asynchronous) dựa trên một tập hợp các chính sách thực thi (execution policy).
Việc tạo ra quá nhiều thread một cách không kiểm soát có thể dẫn đến tình trạng ứng dụng tiêu thụ hết bộ nhớ heap hoặc gây ra gánh nặng quản lý thread quá lớn. Do đó, sử dụng ThreadPool (một nhóm gồm các thread cố định được tạo sẵn và có thể tái sử dụng) thường là một giải pháp tốt hơn. Executor Framework cung cấp các cơ chế và tiện ích giúp đơn giản hóa việc tạo và quản lý các thread pool trong Java.
BlockingQueue là gì? Làm thế nào để ta triển khai bài toán Producer-Consumer với nó?
java.util.concurrent.BlockingQueue
là một dạng Queue đặc biệt hỗ trợ các thao tác chặn (blocking). Điều này có nghĩa là khi cố gắng lấy hoặc xóa một phần tử, thao tác sẽ chờ nếu Queue trống, và khi cố gắng thêm một phần tử, thao tác sẽ chờ nếu Queue đầy.
BlockingQueue
không cho phép lưu trữ giá trị null
vào queue. Nếu ta cố gắng làm vậy, nó sẽ cho ra lỗi NullPointerException
. Tất cả các triển khai của BlockingQueue
đều được thiết kế để mang tính an toàn luồng.
Các phương thức của nó đều mang tính atomic và sử dụng khóa hóa nội bộ cũng như các cơ chế điều phối việc thực thi đồng thời. Interface này là một phần của Collections Framework và hay được dùng để triển khai mô hình Producer-Consumer (chúng tôi đã có bài viết chi tiết về chủ đề này).
Callable và Future là gì?
Callable
(interface java.util.concurrent.Callable
) được giới thiệu trong Java 5. Nó tương tự như Runnable
nhưng có hai điểm khác biệt chính: nó có thể trả về bất cứ Object nào và có thể cho ra một exception. Callable
sử dụng Generics để chỉ định kiểu dữ liệu của giá trị trả về.
Lớp Executors
cung cấp các phương thức hữu ích để thực thi Callable trong một thread pool. Vì các tác vụ callable chạy song song, chúng ta phải chờ đợi Object được trả về.
Các tác vụ Callable trả về đối tượng java.util.concurrent.Future
. Khi sử dụng Future chúng ta có thể tìm hiểu trạng thái của tác vụ Callable và lấy Object được trả về. Phương thức get()
mà nó cung cấp có thể chờ Callable hoàn thành và sau đó trả về kết quả.
Lớp FutureTask là gì?
FutureTask
là một class triển khai cơ sở của interface Future
mà ta có thể dùng với Executors để xử lý bất đồng bộ. Trong phần lớn trường hợp, ta không cần dùng nó. Tuy nhiên, FutureTask
trở nên có ích khi ta cần override các phương thức của nó và muốn giữ lại phần lớn triển khai cơ bản. Bạn có thể kế thừa, mở rộng class này và ghi đè các phương thức tùy theo mong muốn của mình.
Giải thích về các lớp Concurrent Collection
Các lớp Collection truyền thống thường hoạt động theo cơ chế fail-fast. Điều này có nghĩa là nếu collection bị thay đổi bởi một thread khác trong khi một thread đang duyệt qua nó bằng iterator, iterator sẽ cố gắng phát hiện sự thay đổi này và cho ra lỗi ConcurrentModificationException
. Ngược lại, các lớp Concurrent Collection được thiết kế đặc biệt để hỗ trợ truy cập đồng thời từ nhiều thread một cách an toàn. Một số ví dụ phổ biến bao gồm ConcurrentHashMap
, CopyOnWriteArrayList
, và CopyOnWriteArraySet
.
Lớp Executors là gì?
Class Executors
cung cấp các phương thức tiện ích cho ExecutorService
, ScheduledExecutorService
, và ThreadFactory
và các lớp Callable. Nó có khả năng dễ dàng tạo ra thread pool, và cũng là class duy nhất hỗ trợ việc thực thi của các bản triển khai của Callable.
Nêu một số cải tiến của Concurrency API trong Java 8?
Một số cải tiến quan trọng trong Concurrency API trên Java 8 bao gồm:
- Trong
ConcurrentHashMap
: Bổ sung nhiều phương thứccompute()
,forEach()
,forEachEntry()
,forEachKey()
,forEachValue()
,merge()
,reduce()
, vàsearch()
. CompletableFuture
là một future có thể được hoàn thành một cách tường minh (bằng cách thiết lập giá trị và trạng thái của nó).- Phương thức
Executors.newWorkStealingPool()
: tạo ra một thread pool dạng work-stealing để sử dụng tất cả các processor có sẵn làm mức độ song song (parallelism level) mục tiêu.
Tổng kết
Các câu hỏi phỏng vấn về multithreading and concurrency trong Java luôn là điểm nhấn quan trọng. Để thực sự làm chủ những kiến thức này, bạn nên xem mỗi câu hỏi như một đề bài để tự mình nghiên cứu, tìm hiểu sâu hơn, và đừng ngần ngại thực hành trả lời chúng một cách tự tin nhất có thể.