Nếu bạn muốn phát triển những ứng dụng đa luồng thì thread trong Java là một khái niệm quan trọng cần lưu ý. Trong bài viết này, hãy cùng Rikkei Academy tìm hiểu tất tần tật về luồng từ các khái niệm cơ bản nhất cho đến một số kiến thức chuyên sâu khác!
Thread trong Java là gì?
Luồng (thread) trong Java là một đơn vị xử lý độc lập trong chương trình, cho phép thực hiện đa luồng (multithreading) để cải thiện hiệu suất và tận dụng tối đa tài nguyên máy tính. Mỗi luồng là một dòng thực thi độc lập trong chương trình, có thể chạy song song với các luồng khác.
Trong Java, mọi chương trình đều có ít nhất một luồng, gọi là luồng chính (main thread), được cung cấp bởi Java Virtual Machine (JVM) khi chương trình bắt đầu thực thi.
Multithreading là gì trong Java?
Đa luồng (Multithreading) trong Java cho phép thực hiện đồng thời nhiều tác vụ trong cùng một chương trình. Java là một trong những ngôn ngữ lập trình hỗ trợ đa luồng một cách hiệu quả.
Một chương trình Java đa luồng bao gồm hai hoặc nhiều Thread chạy đồng thời, cùng chia sẻ tài nguyên hệ thống như bộ nhớ và CPU. Các Thread có thể làm việc độc lập hoặc phối hợp với nhau để hoàn thành một công việc lớn hơn.
Lợi ích của việc sử dụng Thread trong Java
Việc sử dụng Thread trong Java mang lại nhiều lợi ích, bao gồm:
- Hiệu năng: Sử dụng Thread có thể giúp tận dụng tối đa khả năng đa nhân của bộ xử lý, do đó cải thiện hiệu năng của ứng dụng.
- Phản hồi nhanh: Một ứng dụng đa luồng cho phép người dùng tiếp tục sử dụng các tính năng khác của ứng dụng trong khi một số tác vụ đang chạy ở nền.
- Tận dụng tối đa tài nguyên: Đa luồng giúp chia sẻ tài nguyên hiệu quả giữa các tác vụ, giảm thiểu thời gian chờ đợi và tận dụng tối đa tài nguyên của hệ thống.
- Đơn giản hóa mã nguồn: Chia nhỏ chương trình thành các Thread độc lập giúp mã nguồn dễ hiểu hơn, dễ bảo trì hơn.
Vòng đời của Thread trong Java
Vòng đời của luồng trong Java gồm các giai đoạn sau:
Mới tạo (New)
Khi một Thread mới được khởi tạo nhưng chưa bắt đầu chạy, nó ở trạng thái “New”. Ở trạng thái này, Thread chưa được liên kết với hệ thống phân bổ tài nguyên, và chưa được đưa vào hàng đợi CPU. Để bắt đầu chạy một luồng mới, bạn cần gọi phương thức start() của Thread đó.
Thread myThread = new Thread(new Runnable() {
@Override public void run() { System.out.println(“Thread đang chạy”); } }); |
Chạy (Runnable)
Khi một Thread đã được khởi động bằng phương thức start(), nó chuyển sang trạng thái “Runnable”. Ở trạng thái này, Thread đang chờ đợi CPU để thực thi mã của nó. Trạng thái Runnable không đảm bảo rằng Thread đang thực sự chạy, mà chỉ đơn giản là nó đã sẵn sàng để thực thi và đang chờ đợi tài nguyên.
myThread.start(); |
Chờ (Waiting)
Trong quá trình hoạt động, một Thread có thể phải chờ một điều kiện nào đó để tiếp tục. Ví dụ, Thread có thể chờ một Thread khác hoàn thành công việc, hoặc chờ một tài nguyên được giải phóng. Khi một Thread ở trạng thái “Waiting”, nó không thực thi mã của nó và giải phóng tài nguyên CPU để các Thread khác có thể sử dụng.
Bạn có thể sử dụng các phương thức như wait(), join(), hoặc LockSupport.park() để đưa một Thread vào trạng thái Waiting.
Thread anotherThread = new Thread(new Runnable() {
@Override public void run() { System.out.println(“Thread khác đang chạy”); } }); myThread.join(); // Chờ anotherThread hoàn thành |
Ngủ (Sleeping)
Trạng thái “Sleeping” là khi một Thread đang ngủ trong một khoảng thời gian xác định, sau đó tự động chuyển sang trạng thái Runnable. Khi một Thread ở trạng thái Sleeping, nó không thực thi mã của nó và giải phóng tài nguyên CPU.
Để đưa một luồng vào trạng thái Sleeping, bạn có thể sử dụng phương thức sleep().
try {
Thread.sleep(1000); // Ngủ trong 1 giây } catch (InterruptedException e) { e.printStackTrace(); } |
Kết thúc (Terminated)
Khi một luồng hoàn thành công việc của nó hoặc bị dừng bởi một lý do nào đó, nó chuyển sang trạng thái “Terminated”. Khi một Thread ở trạng thái này, nó không thể tái sử dụng, và bạn cần tạo một Thread mới nếu muốn thực hiện lại công việc đó.
Một số lý do dẫn đến trạng thái Terminated:
- Mã trong phương thức run() của Thread đã hoàn thành.
- Thread gặp phải một ngoại lệ không được xử lý trong phương thức run().
- Phương thức stop() được gọi để dừng Thread (lưu ý: phương thức này đã bị loại bỏ và không nên sử dụng).
@Override
public void run() { System.out.println(“Thread đang chạy”); // Mã ở đây đã hoàn thành, Thread sẽ chuyển sang trạng thái Terminated } |
Thứ tự ưu tiên của Thread trong Java
Mỗi Thread trong Java đều có một mức độ ưu tiên, giúp hệ điều hành xác định thứ tự ưu tiên khi phân bổ tài nguyên và thời gian CPU cho các Thread. Mức ưu tiên của Thread trong Java nằm trong khoảng từ 1 đến 10, với 1 là thấp nhất và 10 là cao nhất. Mặc định, mỗi Thread mới được tạo sẽ có mức ưu tiên bằng với mức ưu tiên của Thread cha.
Java cung cấp một số hằng số để đại diện cho các mức ưu tiên thông dụng:
- Thread.MIN_PRIORITY: Mức ưu tiên tối thiểu (1).
- Thread.NORM_PRIORITY: Mức ưu tiên bình thường (5).
- Thread.MAX_PRIORITY: Mức ưu tiên tối đa (10).
Thread myThread = new Thread();
myThread.setPriority(Thread.MAX_PRIORITY); System.out.println(“Mức ưu tiên của myThread: ” + myThread.getPriority()); |
Lưu ý: việc sử dụng mức ưu tiên Thread không đảm bảo thứ tự chính xác của việc thực thi, vì điều này phụ thuộc vào hệ điều hành và phần cứng. Tuy nhiên, mức ưu tiên tác động đến thứ tự chung của việc thực thi và có thể giúp cải thiện hiệu năng của ứng dụng trong một số trường hợp.
Khởi tạo Thread trong Java
Trong Java, có hai cách chính để tạo một Thread:
- Tạo Thread bằng cách kế thừa lớp java.lang.Thread
- Tạo Thread bằng cách triển khai giao diện java.lang.Runnable
Tạo Thread bằng cách kế thừa lớp java.lang.Thread
Để tạo một Thread bằng cách kế thừa lớp Thread, bạn cần tạo một lớp con của lớp Thread và ghi đè phương thức run(). Phương thức run() chứa mã mà Thread sẽ thực thi khi nó bắt đầu chạy.
public class Main {
public static void main(String[] args) { MyThread myThread = new MyThread(); myThread.start(); // Khởi chạy luồng } } |
Ta sử dụng java.lang.Thread khi:
- Khi lớp của bạn không cần kế thừa từ một lớp khác (vì Java không hỗ trợ đa kế thừa).
- Khi chỉ cần tạo một đối tượng và gọi phương thức start().
Tạo Thread bằng cách triển khai giao diện java.lang.Runnable
Cách thứ hai để tạo Thread là triển khai giao diện Runnable. Để làm điều này, bạn cần tạo một lớp triển khai giao diện Runnable và định nghĩa phương thức run().
class MyRunnable implements Runnable {
@Override public void run() { // Công việc của luồng } } |
Ta sử dụng java.lang.Runnable khi:
- Khi lớp đã kế thừa từ một lớp khác, vì Java cho phép một lớp triển khai nhiều giao diện.
- Tách biệt hóa mã chạy trong Thread (thực thi trong phương thức run()) và việc tạo và quản lý Thread (sử dụng lớp Thread).
- Khi cần chia sẻ tài nguyên giữa các Thread, vì bạn có thể chia sẻ cùng một đối tượng Runnable giữa nhiều Thread.
- Khuyến khích sử dụng với Java 8 trở lên, vì có thể sử dụng biểu thức lambda để tạo một đối tượng Runnable một cách ngắn gọn.
Các phương thức quản lý Thread trong Java cơ bản
Ở phần vòng đời thread trong Java, chúng ta đã biết một số phương thức quản lý Thread trong Java cơ bản. Dưới đây là bảng tổng hợp:
Tên phương thức | Mô tả |
start() | Khởi động Thread và gọi phương thức run(). |
run() | Định nghĩa mã mà Thread sẽ thực thi khi nó bắt đầu chạy. |
setName(String name) | Đặt tên cho Thread. |
getName() | Lấy tên của Thread. |
currentThread() | Lấy đối tượng Thread đại diện cho Thread đang chạy mã. |
getState() | Lấy trạng thái của Thread (ví dụ: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED). |
sleep(long millis) | Dừng thực thi Thread hiện tại trong một khoảng thời gian (được chỉ định bằng mili giây). |
join() | Chờ Thread này hoàn thành trước khi tiếp tục thực thi Thread hiện tại. |
interrupt() | Gửi yêu cầu ngắt đến Thread, điều này không ngay lập tức dừng Thread nhưng đánh dấu trạng thái ngắt. |
isInterrupted() | Kiểm tra xem Thread có đang bị ngắt hay không. |
interrupted() | Kiểm tra xem Thread hiện tại có đang bị ngắt hay không và xóa trạng thái ngắt. |
setPriority(int newPriority) | Đặt mức ưu tiên cho Thread (1 đến 10). |
getPriority() | Lấy mức ưu tiên của Thread. |
Đặt tên và lấy thông tin Thread
Đặt tên cho Thread giúp dễ dàng nhận biết và theo dõi chúng trong quá trình debug hoặc giám sát. Bạn có thể đặt tên cho Thread bằng cách sử dụng phương thức setName() hoặc thông qua một trong các nhà xây dựng có sẵn của lớp Thread.
MyRunnable myRunnable = new MyRunnable(); // Đặt tên cho Thread thông qua phương thức setName() Thread myThread1 = new Thread(myRunnable); myThread1.setName(“MyThread1”); // Đặt tên cho Thread thông qua nhà xây dựng Thread myThread2 = new Thread(myRunnable, “MyThread2”); Để lấy tên của một Thread, bạn có thể sử dụng phương thức getName(). String threadName = myThread1.getName(); System.out.println(“Tên của Thread: ” + threadName); |
Ngoài ra, bạn cũng có thể lấy thông tin về Thread hiện tại bằng cách sử dụng phương thức currentThread() của lớp Thread. Phương thức này trả về một đối tượng Thread đại diện cho Thread đang chạy mã.
Thread currentThread = Thread.currentThread();
System.out.println(“Tên của Thread hiện tại: ” + currentThread.getName()); |
Thread pool trong java
ThreadPool là một tập hợp các luồng làm việc (worker threads) được tạo ra trước và sẵn sàng xử lý các tác vụ. Điều này giúp giảm thiểu chi phí liên quan đến việc tạo và hủy luồng. ThreadPool cho phép quản lý hiệu quả số lượng luồng đang hoạt động trong ứng dụng và tự động điều chỉnh chúng để đáp ứng nhu cầu.
Trong Java, bạn có thể sử dụng các lớp như ThreadPoolExecutor, ScheduledThreadPoolExecutor, hoặc các tiện ích như Executors để tạo và quản lý ThreadPool.
Ví dụ:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; public class ThreadPoolExample { public static void main(String[] args) { // Tạo một ThreadPool với 4 luồng làm việc ExecutorService executorService = Executors.newFixedThreadPool(4); // Gửi 10 tác vụ đến ThreadPool for (int i = 0; i < 10; i++) { final int taskId = i; Runnable task = () -> { System.out.println(“Task ” + taskId + ” is being executed by thread ” + Thread.currentThread().getName()); }; executorService.submit(task); } // Đóng ThreadPool sau khi hoàn thành tất cả các tác vụ executorService.shutdown(); } } |
Một ThreadPool với 4 luồng làm việc và gửi 10 tác vụ đến ThreadPool. ThreadPool sẽ tự động phân công các tác vụ cho các luồng làm việc và thực hiện chúng. Sau khi tất cả các tác vụ hoàn thành, chúng ta đóng ThreadPool.
Đồng bộ hóa trong Java
Trong môi trường đa luồng, các Thread có thể truy cập đồng thời vào các tài nguyên chia sẻ, dẫn đến những vấn đề không mong muốn như Race Condition. Để giải quyết vấn đề này, Java cung cấp cơ chế đồng bộ hóa.
Đồng bộ hóa là gì?
Đồng bộ hóa là một kỹ thuật được sử dụng để điều khiển việc truy cập đồng thời vào các tài nguyên chia sẻ trong môi trường đa luồng. Mục đích của đồng bộ hóa là đảm bảo tính nhất quán và an toàn của dữ liệu khi nhiều luồng truy cập vào cùng một thời điểm.
Trong Java, đồng bộ hóa được thực hiện thông qua việc sử dụng khóa (lock) trên đối tượng.
Từ khoá synchronized
Từ khoá synchronized được sử dụng để đảm bảo rằng chỉ có một luồng có thể truy cập vào một phần mã đồng bộ hóa tại một thời điểm. synchronized có thể được áp dụng cho phương thức hoặc khối mã.
class Counter {
private int count = 0; public synchronized void increment() { count++; } public int getCount() { return count; } } |
Phương thức `increment()` được đánh dấu là `synchronized`, do đó chỉ có một luồng có thể truy cập vào phương thức này tại một thời điểm. Khi một luồng đang thực hiện phương thức `increment()`, các luồng khác sẽ phải chờ đợi cho đến khi luồng hiện tại hoàn thành và giải phóng khóa.
Lưu ý: sử dụng đồng bộ hóa không đúng cách có thể dẫn đến tình trạng khóa chết (deadlock), khiến các luồng không tiếp tục thực thi được.
ReentrantLock trong Thread Java
Java còn cung cấp lớp java.util.concurrent.locks.ReentrantLock để thực hiện đồng bộ hóa một cách tinh vi hơn.
ReentrantLock là một loại khóa được gọi là “reentrant” (có thể nhập lại). Điều này có nghĩa là một luồng đã có khóa truy cập vào tài nguyên có thể tiếp tục cố gắng khóa tài nguyên đó mà không bị chặn, miễn là nó đã giải phóng khóa trước đó. Điều này giúp tránh khóa do một luồng khác đã khóa tài nguyên mà không giải phóng khóa.
Sử dụng ReentrantLock giúp kiểm soát việc khóa và giải khóa một cách linh hoạt hơn so với từ khóa synchronized.
Ví dụ:
import java.util.concurrent.locks.ReentrantLock; class SharedResource { private ReentrantLock lock = new ReentrantLock(); public void sharedMethod() { lock.lock(); // Acquire the lock try { // Code that needs to be synchronized } finally { lock.unlock(); // Release the lock } } } |
Một số lỗi trong Thread Java
Dưới đây là một số lỗi phổ biến mà trong quá trình làm việc với thread trong Java bạn có thể gặp phải:
Race Condition
Đây là tình huống xảy ra khi nhiều luồng truy cập và thay đổi đồng thời một tài nguyên chia sẻ, dẫn đến kết quả không như mong đợi và không đảm bảo tính nhất quán của dữ liệu. Tình trạng này có thể dẫn đến kết quả không chính xác hoặc bất đồng bộ trong chương trình, đặc biệt là khi liên quan đến các tác vụ quan trọng như giao dịch tài chính.
Nguyên nhân
Trong Java, Race Condition thường xảy ra khi sử dụng các biến dữ liệu không được đồng bộ hóa giữa các Thread
Phòng tránh và giải quyết Race Condition
Để giải quyết vấn đề Race Condition trong Java, người lập trình cần sử dụng các phương pháp đồng bộ hoá Thread. Các phương pháp này bao gồm sử dụng các phương thức đồng bộ hóa như synchronized… để đảm bảo rằng các Thread không thể truy cập và thay đổi giá trị của biến dữ liệu đồng thời.
Ngoài ra, cũng có thể sử dụng các phương pháp khác như Locks và Atomic variables để đảm bảo tính đồng bộ và bảo vệ dữ liệu của chương trình.
Thread Deadlock trong Java
Thread Deadlock xảy ra khi hai hoặc nhiều luồng chờ đợi nhau để giải phóng các khóa (lock) mà chúng đang nắm giữ, dẫn đến việc các luồng bị treo vô thời hạn và không thể tiếp tục thực thi. Deadlock thường xảy ra khi các luồng sử dụng đồng bộ hóa không đúng cách trong môi trường đa luồng.
Nguyên nhân
Có bốn nguyên nhân dẫn đến Deadlock trong Java:
- Khóa độc quyền (Mutual Exclusion): Các tài nguyên chỉ có thể được sử dụng bởi một luồng tại một thời điểm.
- Giữ và chờ (Hold and Wait): Một luồng đang giữ một hoặc nhiều tài nguyên và chờ đợi để giành được tài nguyên khác đang được giữ bởi luồng khác.
- Không có lệnh hủy bỏ (No Preemption): Tài nguyên không thể bị giành lại từ một luồng trừ khi luồng đó giải phóng tài nguyên đó.
- Vòng chờ đợi (Circular Wait): Có một chuỗi của luồng đang chờ đợi nhau theo một vòng tròn, trong đó mỗi luồng chờ đợi tài nguyên đang được giữ bởi luồng tiếp theo trong chuỗi.
Phòng tránh và giải quyết Deadlock:
- Phân bổ tài nguyên theo thứ tự và đảm bảo rằng tất cả các luồng đều tuân theo thứ tự đó. Điều này giúp ngăn chặn vòng chờ đợi giữa các luồng.
- Sử dụng tryLock(): cho phép thử lấy một khóa mà không bị chờ vô thời hạn, và có thể sử dụng thời gian chờ nếu cần. Nếu không thể lấy khóa trong khoảng thời gian chờ, luồng sẽ tiếp tục thực thi và tránh deadlock.
- Giải phóng tất cả các tài nguyên trước khi yêu cầu tài nguyên mới: Điều này giúp giảm khả năng xảy ra giữ và chờ (Hold and Wait).
- Phát hiện và phục hồi deadlock: Sử dụng công cụ phát hiện deadlock như JConsole hoặc Java VisualVM để giám sát và phát hiện deadlock nhằm xác định nguyên nhân và tiến hành giải quyết.
Starvation
Starvation là tình trạng mà một Thread hoặc một số Thread không được phép thực hiện công việc của mình trong một khoảng thời gian dài do bị cạnh tranh với các Thread khác trong hệ thống. Tình trạng này xảy ra khi các Thread khác chiếm giữ tài nguyên quan trọng mà Thread đang bị bỏ đói cần sử dụng để hoàn thành công việc của mình.
Nguyên nhân
Trong Java, Starvation có thể xảy ra khi sử dụng các phương thức đồng bộ hoặc khi các Thread đang sử dụng chung một tài nguyên quan trọng.
Phòng tránh và giải quyết Starvation
Để tránh Starvation trong Java, người lập trình cần phải sử dụng các phương pháp tối ưu hóa đồng bộ hóa Thread và đảm bảo rằng các Thread được phân phối tài nguyên một cách hợp lý. Các phương pháp này bao gồm:
- Sử dụng các thuật toán đồng bộ khác nhau như Semaphore hoặc ReentrantLock để quản lý tài nguyên
- Sử dụng các phương thức yield() hoặc sleep() để giảm thiểu sự cạnh tranh giữa các Thread
- Sử dụng Executor Framework để quản lý và phân phối các Thread trong hệ thống đa nhiệm.
Kết luận
Như vậy, chúng ta đã tìm hiểu về cơ bản về thread trong Java cũng như một số vấn đề liên quan đến quản lý luồng, đồng bộ hóa và cách giải quyết các vấn đề phát sinh. Hi vọng rằng bài viết này sẽ giúp bạn nắm vững kiến thức về luồng trong Java và áp dụng thành công vào các dự án thực tế của mình!
Nếu bạn đang muốn tìm hiểu khóa học lập trình Java, tham khảo ngay Rikkei Academy! Với lộ trình tinh gọn, bám sát thực tế công việc và phương pháp đào tạo tiên tiến giúp bạn nhanh chóng trở thành lập trình viên chỉ trong 6 tháng! Đăng ký để nhận tư vấn miễn phí ngay hôm nay!