Thread-Safety là gì và làm thế nào để đạt được nó?

1. Khái quát chung

Java hỗ trợ đa luồng. Điều này có nghĩa là bằng cách chạy đồng thời bytecode trong các luồng worker riêng biệt, JVM có khả năng cải thiện hiệu suất ứng dụng.

Mặc dù đa luồng là một tính năng mạnh mẽ, nhưng nó phải trả giá. Trong môi trường đa luồng, chúng ta cần viết các triển khai theo cách an toàn cho luồng. Điều này có nghĩa là các luồng khác nhau có thể truy cập cùng một tài nguyên mà không để lộ hành vi sai sót hoặc tạo ra kết quả không thể đoán trước. Phương pháp lập trình này được gọi là “an toàn luồng”.

Trong hướng dẫn này, chúng ta sẽ xem xét các cách tiếp cận khác nhau để đạt được nó.

2. Triển khai không trạng thái

Trong hầu hết các trường hợp, lỗi trong các ứng dụng đa luồng là kết quả của trạng thái chia sẻ không chính xác giữa một số luồng.

Do đó, cách tiếp cận đầu tiên mà chúng ta sẽ xem xét là đạt được an toàn luồng bằng cách sử dụng triển khai không trạng thái .

Để hiểu rõ hơn về cách tiếp cận này, chúng ta hãy xem xét một lớp tiện ích đơn giản với một phương thức tĩnh để tính giai thừa của một số:

public class MathUtils { public static BigInteger factorial(int number) { BigInteger f = new BigInteger("1"); for (int i = 2; i <= number; i++) { f = f.multiply(BigInteger.valueOf(i)); } return f; } } 

Phương thức giai thừa () là một hàm xác định không trạng thái. Với một đầu vào cụ thể, nó luôn tạo ra cùng một đầu ra.

Phương pháp này không dựa vào trạng thái bên ngoài cũng như không duy trì trạng thái nào cả . Do đó, nó được coi là an toàn theo luồng và có thể được gọi một cách an toàn bởi nhiều luồng cùng một lúc.

Tất cả các luồng có thể gọi phương thức giai thừa () một cách an toàn và sẽ nhận được kết quả mong đợi mà không can thiệp vào nhau và không làm thay đổi kết quả mà phương thức tạo ra cho các luồng khác.

Do đó, triển khai không trạng thái là cách đơn giản nhất để đạt được sự an toàn của luồng .

3. Triển khai bất biến

Nếu chúng ta cần chia sẻ trạng thái giữa các luồng khác nhau, chúng ta có thể tạo các lớp an toàn cho luồng bằng cách biến chúng thành bất biến .

Tính bất biến là một khái niệm mạnh mẽ, bất khả tri về ngôn ngữ và nó khá dễ đạt được trong Java.

Nói một cách đơn giản, một cá thể lớp là bất biến khi trạng thái bên trong của nó không thể được sửa đổi sau khi nó đã được xây dựng .

Cách dễ nhất để tạo một lớp không thay đổi trong Java là bằng cách khai báo tất cả các trường là privatecuối cùng và không cung cấp các setters:

public class MessageService { private final String message; public MessageService(String message) { this.message = message; } // standard getter }

Một đối tượng MessageService là không thay đổi một cách hiệu quả vì trạng thái của nó không thể thay đổi sau khi xây dựng. Do đó, nó an toàn theo luồng.

Hơn nữa, nếu MessageService thực sự có thể thay đổi, nhưng nhiều chuỗi chỉ có quyền truy cập chỉ đọc vào nó, thì nó cũng an toàn cho chuỗi.

Do đó, tính bất biến chỉ là một cách khác để đạt được sự an toàn của luồng .

4. Trường Thread-Local

Trong lập trình hướng đối tượng (OOP), các đối tượng thực sự cần duy trì trạng thái thông qua các trường và thực hiện hành vi thông qua một hoặc nhiều phương thức.

Nếu chúng ta thực sự cần duy trì trạng thái, chúng ta có thể tạo các lớp an toàn cho luồng không chia sẻ trạng thái giữa các luồng bằng cách làm cho các trường của chúng trở thành luồng cục bộ.

Chúng ta có thể dễ dàng tạo ra các lớp học mà các trường là thread-địa phương bằng cách đơn giản xác định các lĩnh vực tư nhân trong Chủ đề lớp.

Ví dụ, chúng ta có thể định nghĩa một lớp Thread lưu trữ một mảng các số nguyên :

public class ThreadA extends Thread { private final List numbers = Arrays.asList(1, 2, 3, 4, 5, 6); @Override public void run() { numbers.forEach(System.out::println); } }

Trong khi một số khác có thể tổ chức một mảng của chuỗi :

public class ThreadB extends Thread { private final List letters = Arrays.asList("a", "b", "c", "d", "e", "f"); @Override public void run() { letters.forEach(System.out::println); } }

Trong cả hai cách triển khai, các lớp có trạng thái riêng của chúng, nhưng nó không được chia sẻ với các luồng khác. Do đó, các lớp an toàn theo luồng.

Tương tự, chúng ta có thể tạo các trường luồng cục bộ bằng cách gán các cá thể ThreadLocal cho một trường.

Ví dụ, hãy xem xét lớp StateHolder sau :

public class StateHolder { private final String state; // standard constructors / getter }

Chúng ta có thể dễ dàng biến nó trở thành biến cục bộ của chuỗi như sau:

public class ThreadState { public static final ThreadLocal statePerThread = new ThreadLocal() { @Override protected StateHolder initialValue() { return new StateHolder("active"); } }; public static StateHolder getState() { return statePerThread.get(); } }

Trường luồng cục bộ khá giống các trường lớp bình thường, ngoại trừ việc mỗi luồng truy cập chúng thông qua setter / getter sẽ nhận được một bản sao được khởi tạo độc lập của trường để mỗi luồng có trạng thái riêng.

5. Bộ sưu tập được đồng bộ hóa

Chúng tôi có thể dễ dàng tạo các bộ sưu tập an toàn theo chuỗi bằng cách sử dụng bộ trình bao bọc đồng bộ hóa được bao gồm trong khuôn khổ bộ sưu tập.

Ví dụ, chúng tôi có thể sử dụng một trong những trình bao bọc đồng bộ hóa này để tạo một bộ sưu tập an toàn cho chuỗi:

Collection syncCollection = Collections.synchronizedCollection(new ArrayList()); Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6))); Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12))); thread1.start(); thread2.start(); 

Hãy nhớ rằng các tập hợp được đồng bộ hóa sử dụng khóa nội tại trong mỗi phương thức (chúng ta sẽ xem xét khóa nội tại sau).

Điều này có nghĩa là các phương thức chỉ có thể được truy cập bởi một luồng tại một thời điểm, trong khi các luồng khác sẽ bị chặn cho đến khi phương thức được mở khóa bởi luồng đầu tiên.

Do đó, đồng bộ hóa có một hình phạt trong hiệu suất, do logic cơ bản của truy cập đồng bộ.

6. Bộ sưu tập đồng thời

Ngoài ra đối với các bộ sưu tập được đồng bộ hóa, chúng ta có thể sử dụng các bộ sưu tập đồng thời để tạo các bộ sưu tập an toàn theo chuỗi.

Java cung cấp gói java.util.concurrent , chứa một số tập hợp đồng thời, chẳng hạn như ConcurrentHashMap :

Map concurrentMap = new ConcurrentHashMap(); concurrentMap.put("1", "one"); concurrentMap.put("2", "two"); concurrentMap.put("3", "three"); 

Không giống như các đối tác được đồng bộ hóa của chúng , các bộ sưu tập đồng thời đạt được sự an toàn của luồng bằng cách chia dữ liệu của chúng thành các phân đoạn . Trong một ConcurrentHashMap , ví dụ, một số chủ đề có thể có được ổ khóa trên phân đoạn bản đồ khác nhau, do nhiều chủ đề có thể truy cập Bản đồ cùng một lúc.

Các bộ sưu tập đồng thời có hiệu suất cao hơn nhiều so với các bộ sưu tập đồng bộ , do những lợi thế vốn có của truy cập luồng đồng thời.

Điều đáng nói là các bộ sưu tập đồng bộ và đồng thời chỉ làm cho bộ sưu tập trở nên an toàn theo chuỗi chứ không phải nội dung .

7. Vật thể nguyên tử

Cũng có thể đạt được sự an toàn của luồng bằng cách sử dụng tập hợp các lớp nguyên tử mà Java cung cấp, bao gồm AtomicInteger , AtomicLong , AtomicBooleanAtomicReference .

Các lớp nguyên tử cho phép chúng ta thực hiện các hoạt động nguyên tử, an toàn theo luồng, mà không cần sử dụng đồng bộ hóa . Một hoạt động nguyên tử được thực hiện trong một hoạt động cấp máy duy nhất.

Để hiểu vấn đề này giải quyết, hãy xem lớp Counter sau :

public class Counter { private int counter = 0; public void incrementCounter() { counter += 1; } public int getCounter() { return counter; } }

Giả sử rằng trong một điều kiện chạy đua, hai luồng truy cập vào phương thức incrementCounter () cùng một lúc.

In theory, the final value of the counter field will be 2. But we just can't be sure about the result, because the threads are executing the same code block at the same time and incrementation is not atomic.

Let's create a thread-safe implementation of the Counter class by using an AtomicInteger object:

public class AtomicCounter { private final AtomicInteger counter = new AtomicInteger(); public void incrementCounter() { counter.incrementAndGet(); } public int getCounter() { return counter.get(); } }

This is thread-safe because, while incrementation, ++, takes more than one operation, incrementAndGet is atomic.

8. Synchronized Methods

While the earlier approaches are very good for collections and primitives, we will at times need greater control than that.

So, another common approach that we can use for achieving thread-safety is implementing synchronized methods.

Simply put, only one thread can access a synchronized method at a time while blocking access to this method from other threads. Other threads will remain blocked until the first thread finishes or the method throws an exception.

We can create a thread-safe version of incrementCounter() in another way by making it a synchronized method:

public synchronized void incrementCounter() { counter += 1; }

We've created a synchronized method by prefixing the method signature with the synchronized keyword.

Since one thread at a time can access a synchronized method, one thread will execute the incrementCounter() method, and in turn, others will do the same. No overlapping execution will occur whatsoever.

Synchronized methods rely on the use of “intrinsic locks” or “monitor locks”. An intrinsic lock is an implicit internal entity associated with a particular class instance.

In a multithreaded context, the term monitor is just a reference to the role that the lock performs on the associated object, as it enforces exclusive access to a set of specified methods or statements.

When a thread calls a synchronized method, it acquires the intrinsic lock. After the thread finishes executing the method, it releases the lock, hence allowing other threads to acquire the lock and get access to the method.

We can implement synchronization in instance methods, static methods, and statements (synchronized statements).

9. Synchronized Statements

Sometimes, synchronizing an entire method might be overkill if we just need to make a segment of the method thread-safe.

To exemplify this use case, let's refactor the incrementCounter() method:

public void incrementCounter() { // additional unsynced operations synchronized(this) { counter += 1;  } }

The example is trivial, but it shows how to create a synchronized statement. Assuming that the method now performs a few additional operations, which don't require synchronization, we only synchronized the relevant state-modifying section by wrapping it within a synchronized block.

Unlike synchronized methods, synchronized statements must specify the object that provides the intrinsic lock, usually the this reference.

Synchronization is expensive, so with this option, we are able to only synchronize the relevant parts of a method.

9.1. Other Objects as a Lock

We can slightly improve the thread-safe implementation of the Counter class by exploiting another object as a monitor lock, instead of this.

Not only does this provide coordinated access to a shared resource in a multithreaded environment, but also it uses an external entity to enforce exclusive access to the resource:

public class ObjectLockCounter { private int counter = 0; private final Object lock = new Object(); public void incrementCounter() { synchronized(lock) { counter += 1; } } // standard getter }

We use a plain Object instance to enforce mutual exclusion. This implementation is slightly better, as it promotes security at the lock level.

When using this for intrinsic locking, an attacker could cause a deadlock by acquiring the intrinsic lock and triggering a denial of service (DoS) condition.

On the contrary, when using other objects, that private entity is not accessible from the outside. This makes it harder for an attacker to acquire the lock and cause a deadlock.

9.2. Caveats

Even though we can use any Java object as an intrinsic lock, we should avoid using Strings for locking purposes:

public class Class1 { private static final String LOCK = "Lock"; // uses the LOCK as the intrinsic lock } public class Class2 { private static final String LOCK = "Lock"; // uses the LOCK as the intrinsic lock }

At first glance, it seems that these two classes are using two different objects as their lock. However, because of string interning, these two “Lock” values may actually refer to the same object on the string pool. That is, the Class1 and Class2 are sharing the same lock!

This, in turn, may cause some unexpected behaviors in concurrent contexts.

In addition to Strings, we should avoid using any cacheable or reusable objects as intrinsic locks. For example, the Integer.valueOf() method caches small numbers. Therefore, calling Integer.valueOf(1) returns the same object even in different classes.

10. Volatile Fields

Synchronized methods and blocks are handy for addressing variable visibility problems among threads. Even so, the values of regular class fields might be cached by the CPU. Hence, consequent updates to a particular field, even if they're synchronized, might not be visible to other threads.

To prevent this situation, we can use volatile class fields:

public class Counter { private volatile int counter; // standard constructors / getter }

With the volatile keyword, we instruct the JVM and the compiler to store the counter variable in the main memory. That way, we make sure that every time the JVM reads the value of the counter variable, it will actually read it from the main memory, instead of from the CPU cache. Likewise, every time the JVM writes to the counter variable, the value will be written to the main memory.

Moreover, the use of a volatile variable ensures that all variables that are visible to a given thread will be read from the main memory as well.

Let's consider the following example:

public class User { private String name; private volatile int age; // standard constructors / getters }

In this case, each time the JVM writes the agevolatile variable to the main memory, it will write the non-volatile name variable to the main memory as well. This assures that the latest values of both variables are stored in the main memory, so consequent updates to the variables will automatically be visible to other threads.

Similarly, if a thread reads the value of a volatile variable, all the variables visible to the thread will be read from the main memory too.

This extended guarantee that volatile variables provide is known as the full volatile visibility guarantee.

11. Reentrant Locks

Java provides an improved set of Lock implementations, whose behavior is slightly more sophisticated than the intrinsic locks discussed above.

With intrinsic locks, the lock acquisition model is rather rigid: one thread acquires the lock, then executes a method or code block, and finally releases the lock, so other threads can acquire it and access the method.

There's no underlying mechanism that checks the queued threads and gives priority access to the longest waiting threads.

ReentrantLock instances allow us to do exactly that, hence preventing queued threads from suffering some types of resource starvation:

public class ReentrantLockCounter { private int counter; private final ReentrantLock reLock = new ReentrantLock(true); public void incrementCounter() { reLock.lock(); try { counter += 1; } finally { reLock.unlock(); } } // standard constructors / getter }

The ReentrantLock constructor takes an optional fairnessboolean parameter. When set to true, and multiple threads are trying to acquire a lock, the JVM will give priority to the longest waiting thread and grant access to the lock.

12. Read/Write Locks

Another powerful mechanism that we can use for achieving thread-safety is the use of ReadWriteLock implementations.

A ReadWriteLock lock actually uses a pair of associated locks, one for read-only operations and other for writing operations.

Do đó, có thể có nhiều luồng đọc một tài nguyên, miễn là không có luồng nào ghi vào nó. Hơn nữa, luồng ghi vào tài nguyên sẽ ngăn các luồng khác đọc nó .

Chúng ta có thể sử dụng khóa ReadWriteLock như sau:

public class ReentrantReadWriteLockCounter { private int counter; private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); public void incrementCounter() { writeLock.lock(); try { counter += 1; } finally { writeLock.unlock(); } } public int getCounter() { readLock.lock(); try { return counter; } finally { readLock.unlock(); } } // standard constructors } 

13. Kết luận

Trong bài viết này, chúng ta đã tìm hiểu về an toàn luồng trong Java và xem xét chuyên sâu các cách tiếp cận khác nhau để đạt được điều đó .

Như thường lệ, tất cả các mẫu mã hiển thị trong bài viết này đều có sẵn trên GitHub.