자바 멀티스레드 프로그래밍을 배우다 컴파일러 최적화에 대해서 다뤄야 할 기회가 생겨서 이루어보려고 합니다
class SharedClass{
private int x=0;
private int y=0;
public void increment(){
x++;
y++;
}
public void validate(){
if(x<y){
System.out.println("error happened : x="+x+" y="+y);
}
}
public int getX() {
return x;
}
public int getY() {
return y;
}
}
이 클래스에 validate 메서드는 error 가 발생했다는 내용을 출력할 기회가 있을까요?
당연하지만 전혀 없을 것처럼 보입니다
아무리 멀티스레드에서 공유변수에 접근한다고 해도, x를 증가한 이후에, y를 증가시키기에 x>=y는 어떤 경우에도 만족할 수 있을 것으로 보입니다
하지만 실제로 실행시켜보면 어떨까요?
public class ConcurrentTest {
public static void main(String[] args) {
SharedClass sharedClass = new SharedClass();
Thread t1 = new Thread(() -> {
for(int i=0;i<10000;i++){
sharedClass.increment();
}
});
Thread t2 = new Thread(() -> {
for(int i=0;i<10000;i++){
sharedClass.increment();
}
});
Thread t3=new Thread(() -> {
for(int i=0;i<10000;i++){
sharedClass.validate();
}
});
t1.start();
t2.start();
t3.start();
try {
t1.join();
t2.join();
t3.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(sharedClass.getX());
System.out.println(sharedClass.getY());
}
}
를 통해서 실행시켰을 경우에 우리가 생각했던 결과와는 다른 결과가 나오게 됩니다
어마어마하게 많은 에러 발생에 대한 출력이 등장하고, 결과도 당연하지만 제대로 출력이 되지 않습니다
멀티스레드에서 더한 결과가 생각했던 20000이 나오지 않는 것은 정말 유명한 내용이지만
y가 x보다 큰 부분은 상식적으로 이해하기 힘든 결과입니다
이를 알기 위해서는 컴파일러가 해주는 많은 최적화 중 한 가지에 대해서 알아야 하는데요
자바 컴파일러는 논리적 의존 관계가 없는 명령들을 필요에 따라 위치를 변경시킬 수 있습니다
논리적 의존 관계는 앞 부분의 결과가 뒷부분의 결과에 영향을 받는 것을 의미합니다
x++;
y=x++;
같은 경우를 의미하죠
이런 의존관계가 없는 경우에는 자유롭게 위치를 변경하기도 하는데
실제 실행시킬 때는 x++; y++ 이 아니라 y++; x++ 형태로 실행시킬 수 있다는 뜻이죠
다른 메서드에서 x<y 라는 것을 검증하고 있다는 부분은 전혀 알지 못한 상태로 바꾸어 버립니다
이 순서를 보장하기 위한 방법으로는 volatile 키워드가 있습니다
이 명령어는 예전에는 단순하게 cpu 캐시 대신 메인 메모리에서 값을 읽어온다라는 키워드였는데요
지금은 그 이상의 것인 명령의 위치 변경을 어느 정도 억제해 주는 역할을 합니다
volatile 키워드가 붙은 변수가 변경되기 전 명령은 무조건 volatile 키워드 전에 실행하고
volatile 키워드가 붙은 변수가 변경된 후 명령은 무조건 volatile 키워드 후에 실행하는 것을 보장해 줍니다
class SharedClass{
private int x=0;
private volatile int y=0;
public void increment(){
x++;
y++;
if(x<y){
System.out.println("error happened : x="+x+" y="+y);
}
}
public int getX() {
return x;
}
public int getY() {
return y;
}
}
이렇게 하게 되면 x 가 무조건 y 보다 먼저 실행되기에, 적어도 x와 y 사이에 순서는 보장이 된다는 것을 확신할 수 있습니다
그러면 이것만으로 충분할까요?
실제로 실행을 시켜보면 다른 결과가 나오는데요
여전히 작동하지 않는 것을 확인할 수 있습니다
이제 명령 재정리에 대한 부분은 더 이상 문제가 되지 않습니다.
이제 발생한 문제는 단순하게 멀티스레드 간에 변수 공유 문제가 발생한 것인데요
이를 해결하기 위해서 이번에는 Atomic Integer을 통해서 변경해 보도록 하겠습니다
class SharedClass {
private AtomicInteger x = new AtomicInteger(0);
private AtomicInteger y = new AtomicInteger(0);
public void increment() {
x.incrementAndGet();
y.incrementAndGet();
if (x.get() < y.get()) {
System.out.println("error happened : x=" + x + " y=" + y);
}
}
public int getX() {
return x.get();
}
public int getY() {
return y.get();
}
}
이번에도 당연하지만, 실패하는 것을 볼 수 있습니다
총 20000이라는 것은 atomic Integer를 통해서 보장되는 것을 확인할 수 있지만,
실행되는 순서에 대한 보장은 되지 않기에, 아직도 에러가 발생하는 것을 볼 수 있습니다
이때 같은 수지만 에러라고 출력되는 것은 너무 빠르게 변경되기에, 그 사이에 내부 숫자가 변경되기 때문인데요
이를 디버그 모드로 보면 더 명확합니다
이런 형태로 짧은 순간에도 스레드 간 이동이 발생하기에, 실행되던 도중에 다른 스레드가 실행되면 값이 변경되기에 발생하는 문제입니다
class SharedClass {
private AtomicInteger x = new AtomicInteger(0);
private volatile AtomicInteger y = new AtomicInteger(0);
public void increment() {
int large = x.incrementAndGet();
int small = y.incrementAndGet();
if (large < small) {
System.out.println("error happened : x=" + x + " y=" + y);
}
}
public int getX() {
return x.get();
}
public int getY() {
return y.get();
}
}
같은 방식으로 실행해도 제대로 작동하지 않는데요
volatile 키워드는 대입하는 순간에 대한 보장이지, 내부에서 어떤 작업을 하는지에 대해서는 책임져주지 않기 때문입니다
그러면 이를 재정리하는 과정을 막기 위해서 수정을 더 해보도록 하겠습니다
class SharedClass {
private AtomicInteger x = new AtomicInteger(0);
private AtomicInteger y = new AtomicInteger(0);
public synchronized void increment() {
int large = x.incrementAndGet();
int small = y.incrementAndGet();
if (large < small) {
System.out.println("error happened : x=" + x + " y=" + y);
}
}
public int getX() {
return x.get();
}
public int getY() {
return y.get();
}
}
이제 synchronized 키워드를 통해서 동시에 2개의 스레드가 접근하는 것을 막습니다
싱글 스레드에서 접근하는 것이 보장되기에, 컴파일러는 실행시키는 과정에서 명령의 순서를 재정리할 필요를 느끼지 못합니다
그래서 우리가 예상했던 결과인
가 나오게 됩니다
하지만 이 결과는 동시에 여러 스레드가 접근하는 것을 막기에, 멀티 스레드 프로그래밍을 사용하는 이점을 전혀 누릴 수 없게 되는데요
이와 같이 멀티 쓰레드 프로그램 환경에서 공유되는 자원에 대한 관리를 하게 되는 과정은 어렵기에, 웬만해서는 공유되는 자원을 사용하지 않는 것이 좋습니다
읽어보면 좋은 링크
'Java' 카테고리의 다른 글
reflection 을 주의해서 사용해야 하는 이유 (2) | 2023.03.26 |
---|---|
Stream collect 알아보기 (0) | 2023.03.13 |
Java Exception 알아보기 (0) | 2023.02.27 |
Java 초기화 되지 않음을 표현하는 방법 (0) | 2023.02.27 |
Java Generic 딥 다이브 (6) | 2023.02.23 |