Често срещани подводни камъни в Java

1. Въведение

В този урок ще видим някои от най-често срещаните проблеми с паралелността в Java. Също така ще научим как да ги избягваме и основните им причини.

2. Използване на обекти, безопасни за нишките

2.1. Споделяне на обекти

Нишките комуникират предимно чрез споделяне на достъп до едни и същи обекти. Така че, четенето от обект, докато той се променя, може да даде неочаквани резултати. Също така едновременната промяна на обект може да го остави в повредено или несъвместимо състояние.

Основният начин, по който можем да избегнем подобни проблеми на съвпадение и да изградим надежден код, е да работим с неизменяеми обекти . Това е така, защото състоянието им не може да бъде модифицирано от намесата на множество нишки.

Не винаги обаче можем да работим с неизменяеми обекти. В тези случаи трябва да намерим начини да направим нашите изменяеми обекти безопасни за нишки.

2.2. Направете колекциите безопасни за нишки

Както всеки друг обект, колекциите поддържат състояние вътрешно. Това може да бъде променено от множество нишки, променящи колекцията едновременно. И така, един от начините да можем безопасно да работим с колекции в многонишкова среда е да ги синхронизираме :

Map map = Collections.synchronizedMap(new HashMap()); List list = Collections.synchronizedList(new ArrayList());

Като цяло синхронизацията ни помага да постигнем взаимно изключване. По-конкретно, тези колекции могат да бъдат достъпни само по една нишка наведнъж. По този начин можем да избегнем оставянето на колекции в непоследователно състояние.

2.3. Специализирани многонишкови колекции

Сега нека разгледаме сценарий, при който се нуждаем от повече четения, отколкото писания. Използвайки синхронизирана колекция, нашето приложение може да понесе големи последици за производителността. Ако две нишки искат да четат колекцията едновременно, едната трябва да изчака, докато другата приключи.

Поради тази причина Java предоставя едновременни колекции като CopyOnWriteArrayList и ConcurrentHashMap, които могат да бъдат достъпни едновременно от множество нишки:

CopyOnWriteArrayList list = new CopyOnWriteArrayList(); Map map = new ConcurrentHashMap();

В CopyOnWriteArrayList постига нишка безопасност чрез създаване на отделно копие на основния масив за мутагенни операции, като добавяте или премахвате. Въпреки че има по-лоша производителност за операции на запис от Collections.synchronizedList, тя ни осигурява по-добра производителност, когато се нуждаем от значително повече четения, отколкото запис.

ConcurrentHashMap по същество е безопасен за нишки и е по- ефективен от обвивката Collections.synchronizedMap около карта, която не е безопасна за нишки . Това всъщност е безопасна за нишки карта на безопасни за нишки карти, позволяваща различни дейности да се случват едновременно в нейните дъщерни карти.

2.4. Работа с типове, които не са безопасни за нишки

Често използваме вградени обекти като SimpleDateFormat, за да анализираме и форматираме обекти с дата. Класът SimpleDateFormat мутира вътрешното си състояние, докато извършва своите операции.

Трябва да бъдем много внимателни с тях, защото те не са безопасни за нишки. Състоянието им може да стане несъвместимо в многонишково приложение поради неща като състезателни условия.

И така, как можем да използваме SimpleDateFormat безопасно? Имаме няколко възможности:

  • Създавайте нов екземпляр на SimpleDateFormat всеки път, когато се използва
  • Ограничете броя на обектите, създадени с помощта на ThreadLocal обект. Той гарантира, че всяка нишка ще има свой екземпляр на SimpleDateFormat
  • Синхронизирайте едновременния достъп чрез множество нишки със синхронизираната ключова дума или заключване

SimpleDateFormat е само един пример за това. Можем да използваме тези техники с всеки не-безопасен тип.

3. Състезателни условия

Състезателно състояние възниква, когато две или повече нишки имат достъп до споделени данни и те се опитват да ги променят едновременно. По този начин условията на състезанието могат да причинят грешки по време на изпълнение или неочаквани резултати.

3.1. Пример за състезателно състояние

Нека разгледаме следния код:

class Counter { private int counter = 0; public void increment() { counter++; } public int getValue() { return counter; } }

Класът Counter е проектиран така, че всяко извикване на метода за увеличаване ще добави 1 към брояча . Ако обаче към обект Counter се прави препратка от множество нишки, намесата между нишките може да попречи на това да се случи както се очаква.

Можем да разложим оператора counter ++ на 3 стъпки:

  • Извлича текущата стойност на брояча
  • Увеличете извлечената стойност с 1
  • Съхранява увеличената стойност обратно в брояч

Нека сега предположим, че две нишки, thread1 и thread2 , извикват едновременно метода за увеличаване. Техните вплетени действия могат да следват тази последователност:

  • thread1 чете текущата стойност на брояча ; 0
  • thread2 чете текущата стойност на брояча ; 0
  • thread1 увеличава извлечената стойност; резултатът е 1
  • thread2 увеличава извлечената стойност; резултатът е 1
  • thread1 съхранява резултата в брояч ; резултатът е 1
  • thread2 съхранява резултата в брояч ; резултатът е 1

Очаквахме стойността на брояча да бъде 2, но беше 1.

3.2. Синхронизирано решение

Можем да коригираме несъответствието, като синхронизираме критичния код:

class SynchronizedCounter { private int counter = 0; public synchronized void increment() { counter++; } public synchronized int getValue() { return counter; } }

Само една нишка има право да използва синхронизираните методи на обект едновременно, така че това налага последователност в четенето и писането на брояча .

3.3. Вградено решение

Можем да заменим горния код с вграден AtomicInteger обект. Този клас предлага, наред с други, атомни методи за увеличаване на цяло число и е по-добро решение от писането на наш собствен код. Следователно можем да извикаме методите му директно, без да е необходима синхронизация:

AtomicInteger atomicInteger = new AtomicInteger(3); atomicInteger.incrementAndGet();

В този случай SDK решава проблема вместо нас. В противен случай бихме могли да напишем и свой собствен код, капсулирайки критичните секции в персонализиран клас, безопасен за нишки. Този подход ни помага да сведем до минимум сложността и да увеличим повторната употреба на нашия код.

4. Състезателни условия около колекции

4.1. Проблемът

Друга клопка, в която можем да изпаднем, е да мислим, че синхронизираните колекции ни предлагат по-голяма защита, отколкото всъщност.

Нека разгледаме кода по-долу:

List list = Collections.synchronizedList(new ArrayList()); if(!list.contains("foo")) { list.add("foo"); }

Every operation of our list is synchronized, but any combinations of multiple method invocations are not synchronized. More specifically, between the two operations, another thread can modify our collection leading to undesired results.

For example, two threads could enter the if block at the same time and then update the list, each thread adding the foo value to the list.

4.2. A Solution for Lists

We can protect the code from being accessed by more than one thread at a time using synchronization:

synchronized (list) { if (!list.contains("foo")) { list.add("foo"); } }

Rather than adding the synchronized keyword to the functions, we've created a critical section concerning list, which only allows one thread at a time to perform this operation.

We should note that we can use synchronized(list) on other operations on our list object, to provide a guarantee that only one thread at a time can perform any of our operations on this object.

4.3. A Built-In Solution for ConcurrentHashMap

Now, let's consider using a map for the same reason, namely adding an entry only if it's not present.

The ConcurrentHashMap offers a better solution for this type of problem. We can use its atomic putIfAbsent method:

Map map = new ConcurrentHashMap(); map.putIfAbsent("foo", "bar");

Or, if we want to compute the value, its atomic computeIfAbsent method:

map.computeIfAbsent("foo", key -> key + "bar");

We should note that these methods are part of the interface to Map where they offer a convenient way to avoid writing conditional logic around insertion. They really help us out when trying to make multi-threaded calls atomic.

5. Memory Consistency Issues

Memory consistency issues occur when multiple threads have inconsistent views of what should be the same data.

In addition to the main memory, most modern computer architectures are using a hierarchy of caches (L1, L2, and L3 caches) to improve the overall performance. Thus, any thread may cache variables because it provides faster access compared to the main memory.

5.1. The Problem

Let's recall our Counter example:

class Counter { private int counter = 0; public void increment() { counter++; } public int getValue() { return counter; } }

Let's consider the scenario where thread1 increments the counter and then thread2 reads its value. The following sequence of events might happen:

  • thread1 reads the counter value from its own cache; counter is 0
  • thread1 increments the counter and writes it back to its own cache; counter is 1
  • thread2 reads the counter value from its own cache; counter is 0

Of course, the expected sequence of events could happen too and the thread2 will read the correct value (1), but there is no guarantee that changes made by one thread will be visible to other threads every time.

5.2. The Solution

In order to avoid memory consistency errors, we need to establish a happens-before relationship. This relationship is simply a guarantee that memory updates by one specific statement are visible to another specific statement.

There are several strategies that create happens-before relationships. One of them is synchronization, which we've already looked at.

Synchronization ensures both mutual exclusion and memory consistency. However, this comes with a performance cost.

We can also avoid memory consistency problems by using the volatile keyword. Simply put, every change to a volatile variable is always visible to other threads.

Let's rewrite our Counter example using volatile:

class SyncronizedCounter { private volatile int counter = 0; public synchronized void increment() { counter++; } public int getValue() { return counter; } }

We should note that we still need to synchronize the increment operation because volatile doesn't ensure us mutual exclusion. Using simple atomic variable access is more efficient than accessing these variables through synchronized code.

5.3. Non-Atomic long and double Values

So, if we read a variable without proper synchronization, we may see a stale value. For long and double values, quite surprisingly, it's even possible to see completely random values in addition to stale ones.

According to JLS-17, JVM may treat 64-bit operations as two separate 32-bit operations. Therefore, when reading a long or double value, it's possible to read an updated 32-bit along with a stale 32-bit. Consequently, we may observe random-looking long or double values in concurrent contexts.

On the other hand, writes and reads of volatile long and double values are always atomic.

6. Misusing Synchronize

The synchronization mechanism is a powerful tool to achieve thread-safety. It relies on the use of intrinsic and extrinsic locks. Let's also remember the fact that every object has a different lock and only one thread can acquire a lock at a time.

However, if we don't pay attention and carefully choose the right locks for our critical code, unexpected behavior can occur.

6.1. Synchronizing on this Reference

The method-level synchronization comes as a solution to many concurrency issues. However, it can also lead to other concurrency issues if it's overused. This synchronization approach relies on the this reference as a lock, which is also called an intrinsic lock.

We can see in the following examples how a method-level synchronization can be translated into a block-level synchronization with the this reference as a lock.

These methods are equivalent:

public synchronized void foo() { //... }
public void foo() { synchronized(this) { //... } }

When such a method is called by a thread, other threads cannot concurrently access the object. This can reduce concurrency performance as everything ends up running single-threaded. This approach is especially bad when an object is read more often than it is updated.

Moreover, a client of our code might also acquire the this lock. In the worst-case scenario, this operation can lead to a deadlock.

6.2. Deadlock

Deadlock describes a situation where two or more threads block each other, each waiting to acquire a resource held by some other thread.

Let's consider the example:

public class DeadlockExample { public static Object lock1 = new Object(); public static Object lock2 = new Object(); public static void main(String args[]) { Thread threadA = new Thread(() -> { synchronized (lock1) { System.out.println("ThreadA: Holding lock 1..."); sleep(); System.out.println("ThreadA: Waiting for lock 2..."); synchronized (lock2) { System.out.println("ThreadA: Holding lock 1 & 2..."); } } }); Thread threadB = new Thread(() -> { synchronized (lock2) { System.out.println("ThreadB: Holding lock 2..."); sleep(); System.out.println("ThreadB: Waiting for lock 1..."); synchronized (lock1) { System.out.println("ThreadB: Holding lock 1 & 2..."); } } }); threadA.start(); threadB.start(); } }

In the above code we can clearly see that first threadA acquires lock1 and threadB acquires lock2. Then, threadA tries to get the lock2 which is already acquired by threadB and threadB tries to get the lock1 which is already acquired by threadA. So, neither of them will proceed meaning they are in a deadlock.

We can easily fix this issue by changing the order of locks in one of the threads.

We should note that this is just one example, and there are many others that can lead to a deadlock.

7. Conclusion

В тази статия разгледахме няколко примера за проблеми с едновременността, които вероятно ще срещнем в нашите многонишкови приложения.

Първо научихме, че трябва да изберем обекти или операции, които са неизменни или безопасни за нишки.

След това видяхме няколко примера за състезателни условия и как можем да ги избегнем с помощта на механизма за синхронизация. Освен това научихме за свързаните с паметта състезателни условия и как да ги избегнем.

Въпреки че механизмът за синхронизация ни помага да избегнем много проблеми със съвпадението, можем лесно да го използваме неправилно и да създадем други проблеми. Поради тази причина разгледахме няколко проблема, с които можем да се сблъскаме, когато този механизъм се използва зле.

Както обикновено, всички примери, използвани в тази статия, са достъпни в GitHub.