Apakah Keselamatan Benang dan Bagaimana Mencapainya?

1. Gambaran keseluruhan

Java menyokong multithreading di luar kotak. Ini bermakna bahawa dengan menjalankan bytecode secara serentak dalam utas pekerja yang berasingan, JVM mampu meningkatkan prestasi aplikasi.

Walaupun multithreading adalah ciri yang hebat, ia berharga. Dalam persekitaran multithread, kita perlu menulis implementasi dengan cara yang selamat untuk thread. Ini bermaksud bahawa utas yang berbeza dapat mengakses sumber yang sama tanpa mendedahkan tingkah laku yang salah atau menghasilkan hasil yang tidak dapat diramalkan. Metodologi pengaturcaraan ini dikenali sebagai "keselamatan-utas".

Dalam tutorial ini, kita akan melihat pelbagai pendekatan untuk mencapainya.

2. Pelaksanaan Tanpa Statistik

Dalam kebanyakan kes, kesilapan dalam aplikasi multithreaded adalah hasil dari keadaan perkongsian yang salah antara beberapa utas.

Oleh itu, pendekatan pertama yang akan kita perhatikan adalah untuk mencapai keselamatan benang menggunakan pelaksanaan tanpa status .

Untuk lebih memahami pendekatan ini, mari kita pertimbangkan kelas utiliti mudah dengan kaedah statik yang mengira faktorial nombor:

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; } } 

Kaedah faktorial () adalah fungsi deterministik tanpa status. Memandangkan input tertentu, ia selalu menghasilkan output yang sama.

Kaedah ini tidak bergantung pada keadaan luaran dan tidak mengekalkan keadaan sama sekali . Oleh itu, ia dianggap selamat untuk benang dan boleh dipanggil dengan selamat oleh pelbagai utas pada masa yang sama.

Semua utas dapat memanggil kaedah faktorial () dengan selamat dan akan mendapat hasil yang diharapkan tanpa mengganggu satu sama lain dan tanpa mengubah output yang dihasilkan oleh kaedah untuk utas lain.

Oleh itu, pelaksanaan tanpa status adalah kaedah termudah untuk mencapai keselamatan benang .

3. Pelaksanaan yang Tidak Berubah

Sekiranya kita perlu berkongsi keadaan antara benang yang berbeza, kita boleh membuat kelas yang selamat untuk membuat benang dengan tidak berubah .

Kebolehubahan adalah konsep bahasa-agnostik yang kuat dan mudah dicapai di Jawa.

Sederhananya, contoh kelas tidak berubah ketika keadaan dalamannya tidak dapat diubah setelah ia dibina .

Cara termudah untuk membuat kelas yang tidak berubah di Java adalah dengan menyatakan semua bidang sebagai peribadi dan terakhir dan tidak menyediakan setter:

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

A MessageService objek adalah berkesan tidak berubah-ubah sejak negeri tidak boleh berubah selepas pembinaannya. Oleh itu, ia selamat dari benang.

Lebih-lebih lagi, jika MessageService benar-benar berubah-ubah, tetapi beberapa utas hanya mempunyai akses baca sahaja, ia juga selamat untuk utas.

Oleh itu, kebolehubahan adalah cara lain untuk mencapai keselamatan benang .

4. Medan Benang-Benang

Dalam pengaturcaraan berorientasikan objek (OOP), objek sebenarnya perlu mengekalkan keadaan melalui medan dan melaksanakan tingkah laku melalui satu atau lebih kaedah.

Sekiranya kita benar-benar perlu mengekalkan keadaan, kita dapat membuat kelas yang selamat untuk benang yang tidak berkongsi keadaan antara utas dengan menjadikan bidangnya sebagai benang-tempatan.

Kita dapat membuat kelas dengan mudah yang bidangnya adalah thread tempatan dengan hanya menentukan bidang peribadi dalam kelas Thread .

Kita boleh menentukan, sebagai contoh, Thread kelas bahawa kedai-kedai yang mudah untuk integer :

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); } }

Sementara orang lain mungkin mengadakan pelbagai daripada tali :

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); } }

Dalam kedua-dua pelaksanaan, kelas mempunyai keadaan masing-masing, tetapi tidak dikongsi dengan utas lain. Oleh itu, kelasnya selamat digunakan.

Begitu juga, kita boleh membuat medan benang-tempatan dengan menetapkan contoh ThreadLocal ke medan.

Mari kita pertimbangkan, sebagai contoh, kelas StateHolder berikut :

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

Kita boleh menjadikannya pemboleh ubah thread tempatan seperti berikut:

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(); } }

Thread-local field hampir sama dengan medan kelas biasa, kecuali bahawa setiap utas yang mengaksesnya melalui setter / getter mendapat salinan bidang yang dimulakan secara bebas sehingga setiap utas mempunyai keadaannya sendiri.

5. Koleksi Disegerakkan

Kami dapat membuat koleksi yang selamat untuk benang dengan mudah dengan menggunakan set pembungkus penyegerakan yang termasuk dalam kerangka koleksi.

Kita boleh menggunakan, sebagai contoh, salah satu pembungkus penyegerakan ini untuk membuat koleksi selamat utas:

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(); 

Perlu diingat bahawa koleksi yang disegerakkan menggunakan penguncian intrinsik dalam setiap kaedah (kita akan melihat penguncian intrinsik kemudian).

Ini bermaksud bahawa kaedah boleh diakses oleh hanya satu utas pada satu masa, sementara utas lain akan disekat sehingga kaedah dibuka oleh utas pertama.

Oleh itu, penyegerakan mempunyai hukuman dalam prestasi, kerana logik asas akses penyegerakan.

6. Koleksi Serentak

Sebagai alternatif kepada koleksi yang disegerakkan, kami dapat menggunakan koleksi serentak untuk membuat koleksi yang selamat untuk benang.

Java menyediakan pakej java.util.concurrent , yang mengandungi beberapa koleksi serentak, seperti ConcurrentHashMap :

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

Tidak seperti rakan sejajarnya , koleksi serentak mencapai keselamatan benang dengan membahagikan data mereka menjadi segmen . Dalam ConcurrentHashMap , misalnya, beberapa utas dapat memperoleh kunci pada segmen peta yang berlainan, sehingga beberapa utas dapat mengakses Peta pada masa yang sama.

Koleksi serentak jauh lebih baik daripada koleksi yang disegerakkan , kerana kelebihan yang ada pada akses benang serentak.

Perlu disebutkan bahawa koleksi yang disegerakkan dan serentak hanya menjadikan koleksi itu sendiri selamat dari benang dan bukan kandungannya .

7. Objek Atom

Anda juga boleh mencapai keselamatan benang menggunakan kumpulan kelas atom yang disediakan oleh Java, termasuk AtomicInteger , AtomicLong , AtomicBoolean , dan AtomicReference .

Kelas atom membolehkan kita melakukan operasi atom, yang selamat menggunakan benang, tanpa menggunakan penyegerakan . Operasi atom dilaksanakan dalam satu operasi tahap mesin tunggal.

Untuk memahami masalah yang dapat diselesaikan, mari kita lihat kelas Kaunter berikut :

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

Mari kita anggap bahawa dalam keadaan perlumbaan, dua utas mengakses kaedah incrementCounter () pada masa yang sama.

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.

Hasilnya, banyak utas membaca sumber, selagi tidak ada benang yang menulisnya. Lebih-lebih lagi, penulisan benang ke sumber akan menghalang utas lain membacanya .

Kita boleh menggunakan kunci ReadWriteLock seperti berikut:

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. Kesimpulannya

Dalam artikel ini, kami mempelajari apa itu keselamatan benang di Jawa, dan melihat secara mendalam pendekatan yang berbeza untuk mencapainya .

Seperti biasa, semua contoh kod yang ditunjukkan dalam artikel ini terdapat di GitHub.