java [11] 쓰레드, synchronized, volatile, atomic

lsy·2022년 11월 14일
0

자바

목록 보기
13/14

자바 thread 만들기

자바 쓰레드는 두 가지 방법으로 만든다.

  • Thread 클래스를 상속 받아 run() 메서드 오버라이딩 후 해당 구현체 인스턴스 start()실행
  • Runnable 인터페이스를 구현해 run() 메서드 오버라이딩 후 Thread 클래스의 생성자로 해당 구현체 인스턴스를 넣어 Thread 인스턴스 start() 실행
class ThreadInherit extends Thread{
    @Override
    public void run() {
        System.out.println("ThreadInherit");
    }
}

class RunnableImplements implements Runnable{
    @Override
    public void run() {
        System.out.println("RunnableImplements");
    }
}

public class Main {
    public static void main(String[] args) {

        // Thread 클래스를 상속 받아 run() 메서드 오버라이딩 후 실행
        ThreadInherit threadInherit = new ThreadInherit();
        threadInherit.start();

        //Runnable 인터페이스를 구현해 run() 메서드 오버라이딩 후 Thread 클래스의 생성자로 넣어 실행
        Thread thread = new Thread(new RunnableImplements());
        thread.start();
        
    }
}

run()메서드가 아니라 start()메서드로 실행해야 쓰레드가 실행된다. run()메서드는 단순히 클래스에 선언된 메서드를 호출하는 것 뿐이다.

또한 start()메서드는 start0()메서드를 호출하고, 이 native메서드로 선언되어 있다. 해당 native 메서드는 자바 쓰레드를 운영체제의 쓰레드에 1:1로 매핑시킨다. 즉 JVM이 스케줄링하는 것이 아닌 운영체제에게 스케줄링을 맡긴다.

쓰레드 실행 제어

class RunnableImplements implements Runnable{
    @Override
    public void run() {
        System.out.println("RunnableImplements");
    }
}

public class Main {
    public static void main(String[] args) {

        //Runnable 인터페이스를 구현해 run() 메서드 오버라이딩 후 Thread 클래스의 생성자로 넣어 실행
        Thread thread = new Thread(new RunnableImplements());
        thread.start();
        
        System.out.println("main 끝");
    }
}

위 코드의 실행 결과는 다음과 같다.

main 끝
RunnableImplements

또는

RunnableImplements
main 끝

즉, 순서 보장이 되지 않는다. 쓰레드는 비동기적으로 실행되므로 쓰레드가 끝나기를 기다려야 할 경우 join() 메서드를 사용한다.

class RunnableImplements implements Runnable{
    @Override
    public void run() {
        System.out.println("RunnableImplements");
    }
}

public class Main {
    public static void main(String[] args) {

        //Runnable 인터페이스를 구현해 run() 메서드 오버라이딩 후 Thread 클래스의 생성자로 넣어 실행
        Thread thread = new Thread(new RunnableImplements());
        thread.start();
        
        // join 사용
        try {
        	thread.join();
        } catch(Exception e) {}
        System.out.println("main 끝");
    }
}
RunnableImplements
main 끝

join()메서드는 에러 핸들링이 필요하다. 위 코드에서 main 쓰레드는 thread가 끝날 때 까지 thread.join()에서 대기한다. 이제 순서가 보장되는 것을 볼 수 있다.

그 외에도 많은 메서드들이 있다.

static void sleep(long mills) : Thread 클래스의 static 메서드로 이 메서드를 실행한 쓰레드를 입력한 숫자만큼 일시정지 시킨다. 단위는 ms다. 에러 핸들링이 필요하다.
void interrupt() : Thread 클래스의 인스턴스 메서드로 sleep()이나 join()에 의해 일시정지 상태인 쓰레드를 깨워서 실행 대기 상태로 만든다.
static void yield() : Thread 클래스의 자신에게 주어진 실행 시간을 다른 쓰레드에게 양보하고 자신은 실행 대기 상태가 된다.

critical section

자바에서 synchronized키워드로 critical section을 지정할 수 있다. 두 가지 방법이 존재한다.

  • 메서드 자체에 lock을 거는 법
  • 특정 코드 영역에 lock을 거는 법
class Test implements Runnable{
    public int a;

    @Override
    public void run() {
        for (int x = 0; x < 10000; x++) {
            a++;
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Test test = new Test();

        Thread test1 = new Thread(test);
        Thread test2 = new Thread(test);

        test1.start();
        test2.start();

        try {
            test1.join();
            test2.join();
        } catch (Exception e) {}

        System.out.println(test.a);
    }
}

위 코드에서 test 객체 하나를 두 개의 쓰레드가 실행한다. 내용은 필드의 a변수를 1씩 10000번 증가시키는 것인데 당연히 race condition때문에 정확한 값이 나오지 않는다. synchronized로 해결할 수 있다.

class Test implements Runnable{
    public int a;

    @Override
    public void run() {
    	synchronized(this) {
            for (int x = 0; x < 10000; x++) {
                a++;
            }
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Test test = new Test();

        Thread test1 = new Thread(test);
        Thread test2 = new Thread(test);

        test1.start();
        test2.start();

        try {
            test1.join();
            test2.join();
        } catch (Exception e) {}

        System.out.println(test.a);
    }
}

for 문을 synchronized로 lock을 걸면, 두 개의 쓰레드 중 하나가 test객체의 lock을 가져가게 된다. 모든 객체에는 하나의 lock이 존재하며, test1쓰레드가 먼저 synchronized문 안에 진입해 lock를 가져갔다면, test2쓰레드는 test1쓰레드가 락을 반납할 때까지 기다려야 한다.

이렇게 race condition을 해결할 수 있다. 위 예제는 특정 코드 구간을 임계 영역으로 설정했다. 따라서 test1, test2쓰레드 모두 run()메서드까지는 실행할 수 있다. 만약 다음처럼 메서드에 lock을 걸면 어떨까?

class Test implements Runnable{
    public int a;

    @Override
    public synchronized void run() {
    	for (int x = 0; x < 10000; x++) {
            a++;
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Test test = new Test();

        Thread test1 = new Thread(test);
        Thread test2 = new Thread(test);

        test1.start();
        test2.start();

        try {
            test1.join();
            test2.join();
        } catch (Exception e) {}

        System.out.println(test.a);
    }
}

lock을 가져가는 건 똑같다. 다만 메서드 자체에 lock을 거므로 test1이 먼저 run()메서드를 실행해 lock을 가져갔다면, test2쓰레드는 run()메서드를 실행조차 못한다.

메모리 가시성

class Test implements Runnable{
    public boolean flag = false;

    @Override
    public void run() {
        while (!flag);
        System.out.println("반복문 탈출");
    }
}

public class Main {
    public static void main(String[] args) {
        Test test = new Test();

        Thread test1 = new Thread(test);

        test1.start();
        try {
            Thread.sleep(3000);
            System.out.println("플래그를 바꿉니다.");
            test.flag = true;
            test1.join();
        } catch (Exception e) {}
    }
}

위 코드에서 test객체로 쓰레드 실행후 3초 뒤 main쓰레드가 test의 flag를 false로 바꾼다. 따라서 run()메서드의 내용에 의하면 반복문을 탈출해 "반복문 탈출"을 출력하는 것이 우리가 기대하는 바이다. 하지만 "플래그를 바꿉니다"를 출력하고 아무것도 바뀌지 않는다. 여전히 무한 반복을 돈다.

이는 메모리 가시성 때문이다. 각 cpu 코어는 메인 메모리의 내용을 자신의 캐시 메모리에 가져가 저장한 뒤 그것을 사용한다. 즉 flag는 true로 바뀌었지만, test를 실행하고 있는 쓰레드는 여전히 캐시의 내용 false를 보고 있다. 따라서 위와 같은 현상이 생긴다.

이를 해결하려면 cpu가 캐시 메모리말고 메인 메모리의 데이터를 읽게 해야 한다. volatile키워드로 해결할 수 있다.

class Test implements Runnable{
    public volatile boolean flag = false;

    @Override
    public void run() {
        while (!flag);
        System.out.println("반복문 탈출");
    }
}

public class Main {
    public static void main(String[] args) {
        Test test = new Test();

        Thread test1 = new Thread(test);

        test1.start();
        try {
            Thread.sleep(3000);
            System.out.println("플래그를 바꿉니다.");
            test.flag = true;
            test1.join();
        } catch (Exception e) {}
    }
}

위와 같이 flag 변수에 volatile을 추가하면 해결된다.

다만 volatile은 race condition을 해결해주는 키워드는 아니다.

Atomic 변수

위에서 사용한 lock을 사용하는 방법은 쓰레드들의 block을 일으키고 이는 곧 성능 저하로 이어진다. CAS 알고리즘을 사용하는 Atomic 변수를 사용하는 것이 결론이라고 생각한다.

import java.util.concurrent.atomic.AtomicBoolean;

class Test implements Runnable{
    public AtomicBoolean atomicBoolean = new AtomicBoolean();

    @Override
    public synchronized void run() {
        while (!atomicBoolean.get());
        System.out.println("반복문 탈출");
    }
}

public class Main {
    public static void main(String[] args) {
        Test test = new Test();

        Thread test1 = new Thread(test);

        test1.start();
        try {
            Thread.sleep(3000);
            System.out.println("플래그를 바꿉니다.");
            test.atomicBoolean.set(true);
            test1.join();
        } catch (Exception e) {}
    }
}

AtomicBoolean 클래스로 만들면 별다른 키워드 없이도 잘 작동한다. 이 외에도 AtomicInteger, AtomicDouble등 원시 타입에 해당하는 모든 Atomic 클래스들이 존재한다.

CAS 알고리즘은 Compare And Swap의 줄임말이다.

예를 들어, int a = 10;.이 있다고 하자. 어떤 쓰레드가 이 a에다가 10을 더하고 싶어서 메인 메모리에서 자신의 캐시 메모리로 a의 값 10을 저장했다.

이후 이 값을 a + 10 = 20으로 바꾸고, 다시 메인 메모리에 있는 a의 값과 자신이 저장해둔 expected 값을 비교한다. expected 값은 a의 이전 값 즉 10이 되겠다.

만약 메인 메모리에 있는 a의 값과 자신의 expected 값이 같으면 쓰레드가 연산하는 동안 메인 메모리의 a 값이 바뀌지 않았다는 소리다. 따라서 그대로 저장하면 된다.

그렇지 않고 메인 메모리에 있는 a의 값과 자신의 expected 값이 다르다면, cpu 코어가 연산하는 동안 다른 쓰레드가 메인 메모리 a 값을 바꿨다는 소리다. 여기다가 20을 저장한다면 이전 값은 무시되고 race condition이 발생한다.

따라서 이 때는 다시 메인 메모리에서 바뀐 a의 값을 가져온 뒤, 10을 더한다. 예를 들어 a의 값이 15가 됐다면 이제 10을 더해서 25가 되겠다.

이런식으로 반복해서 메인 메모리의 a 값이 expected 값과 같을 때까지 연산을 반복한다. 말 그대로 비교하고 바꾼다.

다만 이런 의문이 생긴다. 그러면 메인 메모리에 있는 a의 값과 자신의 expected 값과 비교했다가 쓰는 동안 그 사이에 다른 쓰레드가 끼어들지 않을 것이라는 것을 보장할 수 있을까?

답은 보장하지 못한다. 적어도 소프트웨어로는 보장이 안된다. 따라서 하드웨어적으로 막아야한다. 현재 모든 상용 cpu에는 이 CAS를 Atomic하게 보장해주는 명령어가 있다.

profile
server를 공부하고 있습니다.

0개의 댓글