Java Concurrency (4) — синхронизированный и CAS

Java задняя часть программист Безопасность

введение

В прошлой статье мы говорили, что volatile гарантирует видимость, упорядоченность и «частичную» атомарность посредством инструкции блокировки. Однако в большинстве проблем параллелизма необходимо гарантировать атомарность операций. У Volatile нет этой функции. В настоящее время необходимо использовать другие средства для достижения цели безопасности потоков. В программировании на Java мы можем использовать блокировку и синхронизированные ключевые слова и операции CAS для обеспечения безопасности потоков.

synchronized

В параллельном программировании на Java наиболее привычным для программистов средством обеспечения синхронизации потоков является ключевое слово synchronized.Самое удобное место для ключевого слова synchronized заключается в том, что ему не нужно отображать снятие блокировки управления, что значительно снижает вероятность ошибок программирования .

В Java 1.5 и более ранних версиях synchronized — не лучший выбор для синхронизации, поскольку из-за частых блокировок и пробуждения потоков во время параллелизма много ресурсов будет потрачено впустую на переключение состояний потоков, в результате чего эффективность параллелизма у synchronized в некоторых случаях не так хорошо, как ReentrantLock. В версии Java 1.6 было сделано много оптимизаций для synchronized, что значительно улучшило производительность synchronized. Пока синхронизация соответствует среде использования, рекомендуется использовать синхронизацию вместо ReentrantLock.

Три способа использования синхронизированного

  1. Измените метод экземпляра, заблокируйте текущий экземпляр и получите блокировку текущего экземпляра перед входом в метод синхронизации.
  2. Измените статический метод, заблокируйте текущий объект класса и получите блокировку текущего объекта класса перед входом в метод синхронизации.
  3. Измените блок кода, укажите объект блокировки, заблокируйте данный объект и получите блокировку данного объекта перед входом в блок синхронизированного кода.

Вы должны быть знакомы с этими тремя способами использования.Одна вещь, которую следует отметить, это то, что модификация статических методов может использоваться одновременно с модификацией методов экземпляра без блокировки, потому что один является модифицированным классом класса, а другой - измененный объект экземпляра. . Следующий пример иллюстрирует это:

public class SynchronizedTest {

	public static synchronized void StaticSyncTest() {

		for (int i = 0; i < 3; i++) {
			System.out.println("StaticSyncTest");
			try {
				TimeUnit.SECONDS.sleep(1);
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
	}

	public synchronized void NonStaticSyncTest() {

		for (int i = 0; i < 3; i++) {
			System.out.println("NonStaticSyncTest");
			try {
				TimeUnit.SECONDS.sleep(1);
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
	}
}

public static void main(String[] args) throws InterruptedException {

    SynchronizedTest synchronizedTest = new SynchronizedTest();
    new Thread(new Runnable() {
		@Override
		public void run() {
			SynchronizedTest.StaticSyncTest();
		}
	}).start();
    new Thread(new Runnable() {
		@Override
		public void run() {
			synchronizedTest.NonStaticSyncTest();
		}
	}).start();
}

//StaticSyncTest
//NonStaticSyncTest
//StaticSyncTest
//NonStaticSyncTest
//StaticSyncTest
//NonStaticSyncTest

В коде мы открываем два потока для блокировки статического метода и метода экземпляра соответственно.Из вывода мы видим, что два потока блокируют разные объекты и могут выполняться одновременно.

Основной принцип синхронизации

Давайте посмотрим на скомпилированный байт-код синхронизированного ключевого слова:

if (null == instance) {   
	synchronized (DoubleCheck.class) {
		if (null == instance) {   
			instance = new DoubleCheck();   
		}
	}
}

Вы можете видеть, что ключевое слово synchronized добавляет две директивы monitorenter и monitorexit до и после блока кода synchronized. Инструкция monitorenter получит объект блокировки.Если объект блокировки будет получен, счетчик блокировки будет увеличен на 1. Если он не получен, текущий поток будет заблокирован. Инструкция monitorexit освобождает объект блокировки и уменьшает счетчик блокировки на 1.

JDK1.6 оптимизация синхронизированного

Оптимизация синхронизированной JDK1.6 в основном отражена во введении концепций «предвзятой блокировки» и «облегченной блокировки», а синхронизированные блокировки можно только обновить, а не понизить:

Я не собираюсь здесь подробно объяснять реализацию каждой блокировки.

Идея предвзятых блокировок означает, что если поток получает блокировку, он переходит в режим смещения из режима без блокировки.Этот шаг выполняется с помощью операций CAS.Поток, входящий в режим смещения, не получает доступ к блоку синхронизированного кода этой блокировки каждый раз. Синхронизация требуется, если только другой поток не получит доступ к блокировке.

Предвзятые блокировки улучшают производительность кода с синхронизацией, но без состязания, то есть, если к вашим синхронизированным блокам кода обращается один и тот же поток в течение длительного времени, предвзятые блокировки повысят эффективность, поскольку уменьшат количество повторных захватов и блокировок. стоимость вскрытия замка. Если ваш синхронизированный блок кода часто используется между несколькими потоками, вы можете использовать параметр -XX:-UseBiasedLocking, чтобы отключить генерацию предвзятой блокировки и избежать переключения между несколькими состояниями блокировки.

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

Идея облегченной блокировки заключается в том, что после того, как несколько потоков входят в блок синхронизированного кода, легковесная блокировка всегда сохраняется, когда несколько потоков не конкурируют, и блокировка приобретается через CAS. Если есть соревнование, операция вращения CAS будет использоваться для получения блокировки в первую очередь.Вращение происходит за очень короткое время с фиксированным количеством вращений.Если получение вращения не удается, оно будет обновлено до тяжеловесной блокировки.

Облегченные блокировки оптимизируют ситуацию, когда несколько потоков входят в синхронизированный блок кода. Когда несколько потоков не конкурируют друг с другом, они могут получить блокировки через CAS, чтобы уменьшить переключение состояний блокировки. Когда несколько потоков конкурируют, вместо прямой блокировки потока используется цикл CAS для попытки получения блокировки, что снижает вероятность блокировки потока и повышает производительность синхронизированной блокировки.

Синхронизированный механизм ожидания пробуждения

Синхронное ожидание пробуждения реализуется тремя методами: notify/notifyAll и wait, выполнение этих трех методов должно выполняться в синхронизированном блоке кода или синхронизированном методе, иначе будет сообщено об ошибке.

Функция метода ожидания состоит в том, чтобы заставить поток, выполняющий в данный момент код, ждать, notify/notifyAll одинаковы, оба уведомляют ожидающий код о продолжении выполнения, notify уведомляет только любой ожидающий поток, notifyAll уведомляет все ожидающие потоки. Метод ожидания отличается от метода ожидания тем, что он снимает блокировку текущего блока кода синхронизации, notify не снимает блокировку при уведомлении любого ожидающего потока и снимает блокировку только после завершения выполнения текущего блока кода синхронизации. Следующий код иллюстрирует это:

public static void main(String[] args) throws InterruptedException {
    waitThread();
    notifyThread();
}

private static Object lockObject = new Object();
	
private static void waitThread() {
    
    Thread watiThread = new Thread(new Runnable() {
        
        @Override
        public void run() {
            
            synchronized (lockObject) {
                System.out.println(Thread.currentThread().getName() + "wait-before");
                
                try {
                    TimeUnit.SECONDS.sleep(2);
                    lockObject.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                
                System.out.println(Thread.currentThread().getName() + "after-wait");
            }
            
        }
    },"waitthread");
    watiThread.start();
}

private static void notifyThread() {
    
    Thread watiThread = new Thread(new Runnable() {
        
        @Override
        public void run() {
            
            synchronized (lockObject) {
                System.out.println(Thread.currentThread().getName() + "notify-before");
                
                lockObject.notify();
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } 
                
                System.out.println(Thread.currentThread().getName() + "after-notify");
            }
            
        }
    },"notifythread");
    watiThread.start();
}

//waitthreadwait-before
//notifythreadnotify-before
//notifythreadafter-notify
//waitthreadafter-wait

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

CAS

В процессе оптимизации синхронизации мы видим большое использование операций CAS.Полное название CAS — Compare And Set (или Compare And Swap).CAS содержит три операнда: расположение в памяти (V), исходное значение (A) и новое значение (B). Проще говоря, операция CAS — это атомарная операция, реализуемая виртуальной машиной. Функция этой атомарной операции — заменить старое значение (A) новым значением (B). Если старое значение (A) не изменилось, замена успешна Замена невозможна, если значение (A) было изменено.

Эту проблему можно проиллюстрировать на примере самоинкрементного кода класса AtomicInteger.Когда синхронизация не используется, следующий код часто не может получить ожидаемое значение 10000, поскольку noncasi[0]++ не является атомарной операцией.

private static void IntegerTest() throws InterruptedException {

    final Integer[] noncasi = new Integer[]{ 0 };

    for (int i = 0; i < 10; i++) {
        Thread thread = new Thread(new Runnable() {

            @Override
            public void run() {
                for (int j = 0; j < 1000; j++) {
                    noncasi[0]++;
                }
            }
        });
        thread.start();
    }
    
    while (Thread.activeCount() > 2) {
        Thread.sleep(10);
    }
    System.out.println(noncasi[0]);
}

//7889

При использовании метода getAndIncrement класса AtomicInteger для реализации автоинкремента это эквивалентно превращению операции casi.getAndIncrement() в атомарную операцию:

private static void AtomicIntegerTest() throws InterruptedException {

    AtomicInteger casi = new AtomicInteger();
    casi.set(0);

    for (int i = 0; i < 10; i++) {
        Thread thread = new Thread(new Runnable() {

            @Override
            public void run() {
                for (int j = 0; j < 1000; j++) {
                    casi.getAndIncrement();
                }
            }
        });
        thread.start();
    }
    while (Thread.activeCount() > 2) {
        Thread.sleep(10);
    }
    System.out.println(casi.get());
}

//10000

Конечно, для достижения цели также можно использовать ключевое слово synchronized, но операции CAS не нужно блокировать и разблокировать и переключать состояние потока, что более эффективно.

Давайте посмотрим, что делает casi.getAndIncrement().До JDK1.8 getAndIncrement был реализован следующим образом (аналогично incrementAndGet):

private volatile int value;

public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}

Автоинкрементирование переменной через compareAndSet. Если автоинкремент выполнен успешно, операция завершается. Если автоинкремент не удался, спин выполнит следующее автоинкрементирование. Поскольку значение переменной модифицируется volatile, через видимость volatile, выполняется каждый get().Может быть получено самое последнее значение, что гарантирует, что операция автоинкремента будет успешной после каждого спина определенное количество раз.

В JDK1.8 метод getAndAddInt напрямую инкапсулирован в атомарную операцию, что более удобно в использовании.

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

Операция CAS является краеугольным камнем реализации параллельных пакетов Java.Она относительно проста для понимания, но также очень важна. Параллельные пакеты Java построены на основе операций CAS и volatile.На следующем рисунке перечислены некоторые диаграммы поддержки классов в пакете J.U.C: