수색…


Pitfall : wait () / notify ()를 잘못 사용했습니다.

object.wait() , object.notify()object.notifyAll() 는 매우 특정한 방법으로 사용하기위한 것입니다. ( http://stackoverflow.com/documentation/java/5409/wait-notify#t=20160811161648303307 참조 )

"분실 된 알림"문제

일반적인 초보자 실수 중 하나는 무조건 object.wait() 호출하는 것입니다.

private final Object lock = new Object();

public void myConsumer() {
    synchronized (lock) {
        lock.wait();     // DON'T DO THIS!!
    }
    doSomething();
}

이것이 잘못된 이유는 lock.notify() 또는 lock.notifyAll() 을 호출하는 다른 스레드에 의존하지만 lock.wait() 이라는 소비자 스레드 전에 다른 스레드가 호출하지 않았 음을 보장하지 않기 때문입니다.

lock.notify()lock.notifyAll() 다른 스레드가 이미 통지를 기다리고 있지 않은 경우 전혀 아무것도하지 않습니다. 이 예제에서 myConsumer() 를 호출하는 스레드는 알림을 수신하기에는 너무 늦으면 영구 중단됩니다.

"잘못된 모니터 상태"버그

잠금을 유지하지 않고 객체에서 wait() 또는 notify() 를 호출하면 JVM은 IllegalMonitorStateException 시킵니다.

public void myConsumer() {
    lock.wait();      // throws exception
    consume();
}

public void myProducer() {
    produce();
    lock.notify();    // throws exception
}

( wait() / notify() 의 디자인은 시스템 경쟁 조건을 피하기 위해 필요하기 때문에 잠금을 유지해야합니다. 잠그지 않고 wait() 또는 notify() 를 호출 할 수 있다면 구현할 수 없습니다 이러한 프리미티브의 주요 유스 케이스 : 조건이 발생하기를 기다린다.)

대기 / 알림 수준이 너무 낮습니다.

wait()notify() 문제를 피하는 가장 좋은 방법은 사용하지 않는 것입니다. 대부분의 동기화 문제는 java.utils.concurrent 패키지에서 사용 가능한 높은 수준의 동기화 객체 (대기열, 장벽, 세마포어 등)를 사용하여 해결할 수 있습니다.

핏방울 - 확장 java.lang.Thread

Thread 클래스 용 javadoc은 Thread 를 정의하고 사용하는 두 가지 방법을 보여줍니다.

커스텀 쓰레드 클래스 사용 :

 class PrimeThread extends Thread {
     long minPrime;
     PrimeThread(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeThread p = new PrimeThread(143);
 p.start();

Runnable 사용 :

 class PrimeRun implements Runnable {
     long minPrime;
     PrimeRun(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeRun p = new PrimeRun(143);
 new Thread(p).start();

(출처 : java.lang.Thread javadoc .)

커스텀 쓰레드 클래스 접근법은 작동하지만 몇 가지 문제가있다 :

  1. 클래식 스레드 풀, executor 또는 ForkJoin 프레임 워크를 사용하는 컨텍스트에서 PrimeThread 를 사용하는 PrimeThread 습니다. ( PrimeThread Runnable 간접적으로 구현하기 때문에 불가능하지는 않지만 커스텀 Thread 클래스를 Runnable 로 사용하는 것은 확실히 서툴기 때문에 클래스의 다른 측면에 따라 실행 가능하지 않을 수 있습니다.)

  2. 다른 방법으로는 실수 할 기회가 더 많습니다. 예를 들어, Thread.start() 위임하지 않고 PrimeThread.start() 를 선언하면 현재 스레드에서 실행되는 "스레드"로 끝납니다.

스레드 논리를 Runnable 에 두는 접근법은 이러한 문제를 방지합니다. 실제로 Runnable 을 구현하기 위해 익명의 클래스 (Java 1.1 이후)를 사용하면 그 결과는 위의 예제보다 더 간결하고 읽기 쉽습니다.

 final long minPrime = ...
 new Thread(new Runnable() {
     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }.start();

람다 식 (Java 8 이후)을 사용하면 위의 예제가 훨씬 더 우아 해집니다.

 final long minPrime = ...
 new Thread(() -> {
    // compute primes larger than minPrime
     . . .
 }).start();

Pitfall - 스레드가 너무 많으면 응용 프로그램의 속도가 느려집니다.

멀티 스레딩에 익숙하지 않은 많은 사람들은 스레드를 사용하면 자동으로 응용 프로그램을 더 빠르게 만들 수 있다고 생각합니다. 실제로, 그것보다 훨씬 복잡합니다. 그러나 확실하게 말할 수있는 한 가지는 모든 컴퓨터에서 동시에 실행할 수있는 스레드 수에 제한이 있다는 것입니다.

  • 컴퓨터에는 고정 된 개수의 코어 (또는 하이퍼 스레드 )가 있습니다.
  • Java 스레드는 실행하기 위해 코어 또는 하이퍼 스레 (hyperthread)로 스케줄 되어야합니다.
  • (사용 가능한) 코어 / 하이퍼 스레드보다 실행 가능한 Java 스레드가 더 많은 경우, 그 중 일부는 대기해야합니다.

이것은 단순히 Java 스레드를 더 많이 만들면 응용 프로그램을 더 빠르고 더 빠르게 만들 수 없다는 것을 의미합니다. 그러나 다른 고려 사항들도 있습니다 :

  • 각 스레드는 스레드 스택에 대해 오프 힙 메모리 영역이 필요합니다. 일반적인 (기본) 스레드 스택 크기는 512KB 또는 1M 바이트입니다. 상당한 수의 스레드가있는 경우 메모리 사용이 중요 할 수 있습니다.

  • 각 활성 스레드는 힙에있는 많은 오브젝트를 참조합니다. 이로 인해 가비지 수집 및 실제 메모리 사용에 영향을 미치는 도달 가능한 개체의 작업 집합이 증가합니다.

  • 스레드 간 전환의 오버 헤드는 간단하지 않습니다. 일반적으로 OS 커널 공간으로 전환하여 스레드 스케줄링을 결정합니다.

  • 스레드 동기화 및 스레드 간 신호 전달 (예 : wait (), notify () / notifyAll)의 오버 헤드 중요 할 수 있습니다 .

응용 프로그램의 세부 사항에 따라 이러한 요소는 일반적으로 스레드 수에 "달콤한 자리"가 있음을 의미합니다. 그 외에도 스레드를 추가하면 성능이 최소한으로 향상되고 성능이 저하 될 수 있습니다.

응용 프로그램이 각각의 새 작업에 대해 작성하는 경우 예기치 않은 작업 부하 증가 (예 : 높은 요청 속도)로 인해 파국적 인 동작이 발생할 수 있습니다.

더 나은 방법은 크기를 제어 할 수있는 (정적 또는 동적으로) 제한된 스레드 풀을 사용하는 것입니다. 할 일이 너무 많으면 응용 프로그램이 요청을 대기열에 넣어야합니다. ExecutorService 를 사용하면 스레드 풀 관리 및 작업 대기열을 처리합니다.

핏방울 - 스레드 생성은 비교적 비쌉니다.

다음 두 가지 마이크로 벤치 마크를 고려하십시오.

첫 번째 벤치 마크는 단순히 스레드를 생성, 시작 및 조인합니다. thread의 Runnable 는 동작하지 않습니다.

public class ThreadTest {
    public static void main(String[] args) throws Exception {
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Thread t = new Thread(new Runnable() {
                        public void run() {
                }});
                t.start();
                t.join();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ThreadTest 
34627.91355
33596.66021
33661.19084
33699.44895
33603.097
33759.3928
33671.5719
33619.46809
33679.92508
33500.32862
33409.70188
33475.70541
33925.87848
33672.89529
^C

64 비트 Java 8 u101을 사용하는 Linux를 실행하는 일반적인 최신 PC에서이 벤치 마크는 33.6 - 33.9 마이크로 초 사이의 쓰레드를 생성, 시작 및 결합하는 데 걸리는 평균 시간을 보여줍니다.

두 번째 벤치 마크는 첫 번째 벤치마킹과 동일하지만 ExecutorService 를 사용하여 작업을 제출하고 Future 를 사용하여 작업의 끝으로 랑데부를 수행합니다.

import java.util.concurrent.*;

public class ExecutorTest {
    public static void main(String[] args) throws Exception {
        ExecutorService exec = Executors.newCachedThreadPool();
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Future<?> future = exec.submit(new Runnable() {
                    public void run() {
                    }
                });
                future.get();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ExecutorTest
6714.66053
5418.24901
5571.65213
5307.83651
5294.44132
5370.69978
5291.83493
5386.23932
5384.06842
5293.14126
5445.17405
5389.70685
^C

보시다시피, 평균은 5.3 ~ 5.6 마이크로 초입니다.

실제 시간은 다양한 요소에 따라 달라 지지만이 두 결과의 차이는 중요합니다. 새로운 스레드를 만드는 것보다 스레드 풀을 사용하여 스레드를 재활용하는 것이 분명히 빠릅니다.

핏방울 : 공유 변수는 적절한 동기화가 필요합니다.

다음 예제를 고려하십시오.

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (!stop) {
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        tt.stop = true;            // Tell child thread to stop.
    }
}

이 프로그램의 목적은 스레드를 시작하고 1000 밀리 초 동안 실행 한 다음 stop 플래그를 설정하여 stop 하도록하는 것입니다.

의도 한대로 작동합니까?

어쩌면 예, 아니오 일 수 있습니다.

응용 프로그램은 main 메소드가 리턴 될 때 반드시 멈추는 것은 아닙니다. 다른 스레드가 작성되어 해당 스레드가 디먼 스레드로 표시되지 않은 경우, 응용 프로그램은 기본 스레드가 종료 된 후에도 계속 실행됩니다. 이 예에서는 자식 스레드가 끝날 때까지 응용 프로그램이 계속 실행됨을 나타냅니다. tt.stoptrue 로 설정되면 발생 true .

그러나 그것은 사실 엄밀히 사실이 아닙니다. 사실, 자식 스레드는 값 true stop관찰 한 후에 stop true . 그럴 수 있습니까? 어쩌면 예, 아마도 아닐 수도 있습니다.

Java 언어 사양 메모리 읽기 및 소스 코드에서 문장의 순서에 따라, 해당 스레드에 볼 수 있습니다 스레드에서 만들어 쓰도록 보장한다. 그러나, 일반적으로, 이것은 한 쓰레드가 쓰고 다른 쓰레드 (이후)가 읽을 때 보장되지 않는다. 가시성을 확보하려면 쓰기와 후속 읽기 간의 일련의 사태 발생 가능성 관계가 있어야합니다. 위의 예에서 stop 플래그에 대한 업데이트와 같은 체인이 없으므로 자식 스레드에서 stop 변경이 true 표시되는 것은 아닙니다.

(작성자 주 : 자바 메모리 모델에 대한 자세한 기술 정보를 보려면 별도의 주제가 있어야한다.)

어떻게 문제를 해결할 수 있습니까?

이 경우 stop 업데이트가 표시되는지 확인하는 두 가지 간단한 방법이 있습니다.

  1. volatile 이되도록 stop 를 선언하십시오. 즉

     private volatile boolean stop = false;
    

    volatile 변수의 경우, JLS는, 1 개의 thread에 의한 write와 2 번째의 thread에 의한 read의 사이에, happen-before의 관계가 존재하는 것을 지정합니다.

  2. 뮤텍스를 사용하여 다음과 같이 동기화하십시오.

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (true) {
            synchronize (this) {
                if (stop) {
                    break;
                }
            }
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        synchronize (tt) {
            tt.stop = true;        // Tell child thread to stop.
        }
    }
}

JLS는, 상호 배제가 보증되는 것 외, 1 개의 thread 내의 뮤텍스의 발행과 2 번째의 thread 내의 같은 뮤텍스의 취득간에 일어난 일 이있다.

그러나 할당 원자가 아닌가요?

예, 그렇습니다!

그러나이 사실이 모든 스레드에 업데이트의 영향을 동시에 표시 할 수 있음을 의미하지는 않습니다. 상황에 맞는 관계의 적절한 연쇄 만이이를 보장 할 것이다.

왜 그들은 이것을 했습니까?

Java로 멀티 스레드 프로그래밍을 처음 수행하는 프로그래머는 메모리 모델이 어렵다는 것을 알게됩니다. 프로그램은 직관적 인 방식으로 작동합니다. 자연스러운 기대는 쓰기가 균일하게 보이기 때문입니다. 그렇다면 왜 Java 디자이너가 이런 식으로 메모리 모델을 설계해야 하는가?

실제로 성능과 사용 편의성 사이에서 절충안이 내려집니다 (프로그래머에게).

최신 컴퓨터 아키텍처는 개별 레지스터 세트가있는 다중 프로세서 (코어)로 구성됩니다. 주 메모리는 모든 프로세서 또는 프로세서 그룹에 액세스 할 수 있습니다. 현대 컴퓨터 하드웨어의 또 다른 특성은 레지스터에 대한 액세스가 주 메모리에 대한 액세스보다 일반적으로 액세스 속도가 훨씬 빠르다는 것입니다. 코어의 수가 증가함에 따라 주 메모리에 대한 읽기 및 쓰기가 시스템의 주요 성능 병목 현상이 될 수 있음을 쉽게 알 수 있습니다.

이러한 불일치는 프로세서 코어와 주 메모리간에 하나 이상의 수준의 메모리 캐싱을 구현함으로써 해결됩니다. 각 코어는 캐시를 통해 메모리 셀에 액세스합니다. 일반적으로 주 메모리 읽기는 캐시 미스가있는 경우에만 발생하며 주 메모리 쓰기는 캐시 라인을 플러시해야 할 때만 발생합니다. 각 코어의 작업 메모리 세트가 캐시에 들어갈 수있는 응용 프로그램의 경우 코어 속도는 더 이상 주 메모리 속도 / 대역폭으로 제한되지 않습니다.

그러나 이것은 다중 코어가 공유 변수를 읽고 쓰고있을 때 우리에게 새로운 문제를 안겨줍니다. 최신 버전의 변수가 하나의 코어 캐시에있을 수 있습니다. 코어가 캐시 라인을 메인 메모리로 플러시하지 않고 다른 코어가 이전 버전의 캐시 된 사본을 무효화하지 않는 한, 일부는 오래된 버전의 변수를 볼 수 있습니다. 그러나 캐시가 메인 메모리 대역폭을 불필요하게 소비하는 캐시 쓰기 ( "경우에 따라 다른 코어에 의한 읽기"가있을 때마다)가 될 때마다 메모리로 플러시되는 경우.

하드웨어 명령어 세트 레벨에서 사용되는 표준 솔루션은 캐시 무효화 및 캐시 쓰기 스루 (cache write-through)에 대한 지침을 제공하고이를 컴파일러에게 맡겨 사용시기를 결정하는 것입니다.

Java로 돌아 가기. 메모리 모델은 Java 컴파일러가 실제로 필요하지 않은 곳에서 캐시 무효화 및 연속 기입 (write-through) 명령어를 발행 할 필요가 없도록 설계되었습니다. 프로그래머는 메모리 시정이 필요함을 나타 내기 위해 적절한 동기화 메커니즘 (예 : 원시 뮤텍스, volatile , 상위 수준 동시성 클래스 등)을 사용합니다. 발생 전 관계가없는 경우 Java 컴파일러는 캐시 조작 (또는 이와 유사한)이 필요 없다고 가정 할 수 있습니다.

이것은 멀티 스레드 응용 프로그램에 상당한 성능 이점이 있지만 올바른 멀티 스레드 응용 프로그램을 작성하는 것이 간단한 문제가 아니라는 단점이 있습니다. 프로그래머 자신이하는 일을 이해해야합니다.

왜 이것을 재현 할 수 없습니까?

이와 같은 문제가 재현하기 어려운 여러 가지 이유가 있습니다.

  1. 전술 한 바와 같이, 메모리 가시성을 취급하지 않는 결과가 제대로 문제를 발급 컴파일 된 응용 프로그램이 제대로 메모리 캐시를 처리하지 않습니다 일반적이다. 그러나 위에서 언급했듯이 메모리 캐시는 종종 어쨌든 플러시됩니다.

  2. 하드웨어 플랫폼을 변경하면 메모리 캐시의 특성이 변경 될 수 있습니다. 응용 프로그램이 올바르게 동기화되지 않으면 다른 동작이 발생할 수 있습니다.

  3. 당신은 우연한 동시성의 결과를 관찰하고 있을지도 모른다. 예를 들어, 흔적을 추가하면 일반적으로 캐시 플러시를 유발하는 I / O 스트림의 장면 뒤에서 동기화가 발생합니다. 그래서 추가 traceprints는 종종 다르게 동작하는 응용 프로그램을 발생합니다.

  4. 디버거에서 응용 프로그램을 실행하면 JIT 컴파일러에 의해 다르게 컴파일됩니다. 중단 점과 단일 스테핑은이 점을 악화시킵니다. 이러한 효과는 종종 응용 프로그램의 동작 방식을 변경합니다.

이러한 것들은 부적절한 동기화로 인한 버그를 해결하기가 특히 어렵습니다.



Modified text is an extract of the original Stack Overflow Documentation
아래 라이선스 CC BY-SA 3.0
와 제휴하지 않음 Stack Overflow