코드 스테이츠

코드 스테이츠 - Java 심화(Effective) 3

한휘용 2023. 5. 8. 19:50
728x90

스레드(Thread)

 

프로세스(Process)와 스레드(Thread)

 

프로세스는 실행 중인 애플리케이션을 의미한다. 즉, 애플리케이션을 실행하면 운영체제로부터 실행에 필요한 만큼의 메모리를 할당받아 프로세스가 된다.

 

프로세스는 데이터, 컴퓨터 자원, 그리고 스레드로 구성되는데, 스레드는 데이터와 애플리케이션이 확보한 자원을 활용하여 소스 코드를 실행한다. 즉, 스레드는 하나의 코드 실행 흐름이라고 볼 수 있다.

 

 

메인 스레드(Main thread)

 

자바 애플리케이션을 실행하면 가장 먼저 실행되는 메서드는 main 메서드이며, 메인 스레드가 main 메서드를 실행시켜 준다. 메인 스레드는 main 메서드의 코드를 처음부터 끝까지 차례대로 실행시키며, 코드의 끝을 만나거나 return문을 만나면 실행을 종료합니다.

 

만약, 어떤 자바 애플리케이션의 소스 코드가 싱글 스레드로 작성되었다면, 그 애플리케이션이 실행되어 프로세스가 될 때 오로지 메인 스레드만 가지는 싱글 스레드 프로세스가 될 것이다. 반면, 메인 스레드에서 또 다른 스레드를 생성하여 실행시킨다면 해당 애플리케이션은 멀티 스레드로 동작하게 된다.

 

 

작업 스레드 생성과 실행

 

메인 스레드 외에 별도의 작업 스레드를 활용한다는 것은, 작업 스레드가 수행할 코드를 작성하고, 작업 스레드를 생성하여 실행시키는 것을 의미한다.

 

그런데 자바는 객체지향 언어이므로 모든 자바 코드는 클래스 안에 작성된다. 따라서 스레드가 수행할 코드도 클래스 내부에 작성해주어야 하며, run()이라는 메서드 내에 스레드가 처리할 작업을 작성하도록 규정되어 있다.

 

run() 메서드는 Runnable 인터페이스와 Thread 클래스에 정의되어 있다.

따라서, 작업 스레드를 두가지 방법으로 생성하고 실행할 수 있다.

 

  • 첫 번째 방법
    • Runnable 인터페이스를 구현한 객체에서 run()을 구현하여 스레드를 생성하고 실행하는 방법
  • 두 번째 방법
    • Thread 클래스를 상속받은 하위 클래스에서 run()을 구현하여 스레드를 생성하고 실행하는 방법

 

 

1. Runnable 인터페이스를 구현한 객체에서 run()을 구현하여 스레드를 생성하고 실행하는 방법

 

먼저, Runnable 인터페이스를 구현한 객체를 만들자.

임의 클래스를 생성하고, Runnable을 구현하도록 한다. Runnable에는 run()이 정의되어 있으므로 반드시 run()을 구현해야한다.

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

    }
}

// Runnable 인터페이스를 구현하는 클래스
class ThreadTask1 implements Runnable {
    public void run() {

    }
}

 

그다음, run()의 메서드 바디에 새롭게 생성된 작업 스레드가 수행할 코드를 적어주면 된다.

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

    }
}

class ThreadTask1 implements Runnable {

    // run() 메서드 바디에 스레드가 수행할 작업 내용 작성
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.print("#");
        }
    }
}

 

이제 스레드를 생성해야 한다. Runnable 인터페이스를 구현한 객체를 활용하여 스레드를 생성할 때는 아래와 같이 Runnable 구현 객체를 인자로 전달하면서 Thread 클래스를 인스턴스화한다.

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

        // Runnable 인터페이스를 구현한 객체 생성
        Runnable task1 = new ThreadTask1();

        // Runnable 구현 객체를 인자로 전달하면서 Thread 클래스를 인스턴스화 하여 스레드를 생성
        Thread thread1 = new Thread(task1);

        // 위의 두 줄을 아래와 같이 한 줄로 축약할 수도 있습니다. 
        // Thread thread1 = new Thread(new ThreadTask1());

    }
}

class ThreadTask1 implements Runnable {
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.print("#");
        }
    }
}

 

스레드를 생성하는 것만으로는 run() 내부의 코드가 실행되지는 않는다.

run() 메서드 내부의 코드를 실행하려면 start() 메서드를 아래와 같이 호출하여 스레드를 실행시켜주어야 한다.

public class ThreadExample1 {
    public static void main(String[] args) {
        Runnable task1 = new ThreadTask1();
        Thread thread1 = new Thread(task1);

        // 작업 스레드를 실행시켜, run() 내부의 코드를 처리하도록 합니다. 
        thread1.start();
    }
}

class ThreadTask1 implements Runnable {
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.print("#");
        }
    }
}

 

main 메서드에 아래와 같이 반복문을 추가한 후, 코드를 실행후 결과를 확인한다.

public class ThreadExample1 {
    public static void main(String[] args) {
        Runnable task1 = new ThreadTask1();
        Thread thread1 = new Thread(task1);

        thread1.start();

        // 반복문 추가
        for (int i = 0; i < 100; i++) {
            System.out.print("@");
        }
    }
}

class ThreadTask1 implements Runnable {
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.print("#");
        }
    }
}
//출력값
"C:\Program Files\Zulu\zulu-11\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\lib\idea_rt.jar=59535:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\bin" -Dfile.encoding=UTF-8 -classpath "C:\Users\gksdy\OneDrive\바탕 화면\ExampleProject\out\production\ExampleProject" Main
@@@@@@@@@@@######@@@@@############################@#########@@@@@@@@@@@@@@@@############@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@###########################################
Process finished with exit code 0

 

출력 결과를 해석하면 다음과 같다.

  • @는 main 메서드의 반복문에서 출력한 문자다. 즉, @는 메인 스레드의 반복문 코드 실행에 의해 출력되었다.
  • #는 run() 메서드의 반복문에서 출력한 문자다. 즉, #는 작업 스레드의 반복문 코드 실행에 의해 출력되었다.
  • @와 #는 섞여 있다. 메인 스레드와 작업 스레드가 동시에 병렬로 실행되면서 각각 main 메서드와 run() 메서드의 코드를 실행시켰기 때문에 두 가지 문자가 섞여서 출력된 것이다.

 

2. Thread 클래스를 상속받은 하위 클래스에서 run()을 구현하여 스레드를 생성하고 실행하는 방법

 

위에서 작성한 것과 같이 Thread 클래스를 상속받는 하위 클래스를 만들어준다.

Thread 클래스에는 run() 메서드가 정의되어 있으며, 따라서 run() 메서드를 오버라이딩해주어야 한다.

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

    }
}

// Thread 클래스를 상속받는 클래스 작성
class ThreadTask2 extends Thread {
    public void run() {

    }
}

 

그다음, run() 메서드 바디에 새롭게 생성될 스레드가 수행할 작업을 작성한다.

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

    }
}

class ThreadTask2 extends Thread {
	
    // run() 메서드 바디에 스레드가 수행할 작업 내용 작성
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.print("#");
        }
    }
}

 

이제 run() 내의 코드를 실행할 스레드를 생성해야 한다.

첫 번째 방법과의 차이점은, 첫 번째 방법과 달리 Thread 클래스를 직접 인스턴스화하지 않는다는 점이다.

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

        // Thread 클래스를 상속받은 클래스를 인스턴스화하여 스레드를 생성
        ThreadTask2 thread2 = new ThreadTask2();
    }
}

class ThreadTask2 extends Thread {
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.print("#");
        }
    }
}

 

마지막으로, 첫 번째 방법과 동일하게 start() 메서드를 실행시켜 주고, main 메서드에 반복문 코드를 추가한 후, 코드를 실행 후 결과를 확인한다.

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

        ThreadTask2 thread2 = new ThreadTask2();

        // 작업 스레드를 실행시켜, run() 내부의 코드를 처리하도록 합니다. 
        thread2.start();

        // 반복문 추가
        for (int i = 0; i < 100; i++) {
            System.out.print("@");
        }
    }
}

class ThreadTask2 extends Thread {
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.print("#");
        }
    }
}
//출력값
"C:\Program Files\Zulu\zulu-11\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\lib\idea_rt.jar=59507:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\bin" -Dfile.encoding=UTF-8 -classpath "C:\Users\gksdy\OneDrive\바탕 화면\ExampleProject\out\production\ExampleProject" Main
@####################################################################################################@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Process finished with exit code 0

 

출력값이 두 가지 방법 모두 유사한 것을 알 수 있었다.

두 가지 방법은 모두 작업 스레드를 만들고, run() 메서드에 작성된 코드를 처리하는 동일한 내부 동작을 수행한다.

 

 

익명 객체를 사용하여 스레드 생성하고 실행하기

 

앞서, 스레드가 수행할 동작은 run() 메서드의 바디에 작성해야 하며, 자바는 객체지향 언어이므로 클래스 안에 코드를 작성해야 한다고 했다. 이에 따라, ThreadTask1, ThreadTask2를 만들어 그 안에 run() 메서드를 정의했다.

 

그러나, 꼭 이렇게 클래스를 따로 정의하지 않고도 익명 객체를 활용하여 스레드를 생성하고 실행시킬 수 있다.

아래 예제를 확인해보자. 익명객체의 대한 이해가 필요한 부분이다.

 

 

Runnable 익명 구현 객체를 활용한 스레드 생성 및 실행

public class ThreadExample1 {
    public static void main(String[] args) {
				
        // 익명 Runnable 구현 객체를 활용하여 스레드 생성
        Thread thread1 = new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 100; i++) {
                    System.out.print("#");
                }
            }
        });

        thread1.start();

        for (int i = 0; i < 100; i++) {
            System.out.print("@");
        }
    }
}

 

Thread 익명 하위 객체를 활용한 스레드 생성 및 실행

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

        // 익명 Thread 하위 객체를 활용한 스레드 생성
        Thread thread2 = new Thread() {
            public void run() {
                for (int i = 0; i < 100; i++) {
                    System.out.print("#");
                }
            }
        };

        thread2.start();

        for (int i = 0; i < 100; i++) {
            System.out.print("@");
        }
    }
}

 

 

스레드의 이름

 

메인스레드는 “main”이라는 이름을 가진다.

그 외에 추가로 생성한 스레드는 기본적으로 “Thread-n”이라는 이름을 가진다.

 

 

스레드의 이름 조회하기

 

스레드의 이름은 아래와 같이 스레드의_참조값.getName()으로 조회할 수 있다.

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

        Thread thread3 = new Thread(new Runnable() {
            public void run() {
                System.out.println("Get Thread Name");
            }
        });

        thread3.start();

        System.out.println("thread3.getName() = " + thread3.getName());
    }
}
//출력값
"C:\Program Files\Zulu\zulu-11\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\lib\idea_rt.jar=60621:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\bin" -Dfile.encoding=UTF-8 -classpath "C:\Users\gksdy\OneDrive\바탕 화면\ExampleProject\out\production\ExampleProject" Main
Get Thread Name
thread3.getName() = Thread-0

Process finished with exit code 0

 

 

스레드 이름 설정하기

 

스레드의 이름은 스레드의_참조값.setName()으로 설정할 수 있다.

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

        Thread thread4 = new Thread(new Runnable() {
            public void run() {
                System.out.println("Set And Get Thread Name");
            }
        });

        thread4.start();

        System.out.println("thread4.getName() = " + thread4.getName());

        thread4.setName("Code States");

        System.out.println("thread4.getName() = " + thread4.getName());
    }
}
// 출력값
"C:\Program Files\Zulu\zulu-11\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\lib\idea_rt.jar=60703:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\bin" -Dfile.encoding=UTF-8 -classpath "C:\Users\gksdy\OneDrive\바탕 화면\ExampleProject\out\production\ExampleProject" Main
Set And Get Thread Name
thread4.getName() = Thread-0
thread4.getName() = Code States

Process finished with exit code 0

 

 

스레드 인스턴스 주소값 얻기

 

스레드의 이름을 조회하고 설정하는 위 두 메서드는 모두 Thread 클래스로부터 인스턴스화된 인스턴스의 메서드이므로, 호출할 때 스레드 객체의 참조가 필요하다.

 

만약, 실행 중인 스레드의 주소값을 사용해야 하는 상황이 발생한다면 Thread 클래스의 정적 메서드인 currentThread()를 사용하면 된다.

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

        Thread thread1 = new Thread(new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread().getName());
            }
        });

        thread1.start();
        System.out.println(Thread.currentThread().getName());
    }
}
//출력값
"C:\Program Files\Zulu\zulu-11\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\lib\idea_rt.jar=61008:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\bin" -Dfile.encoding=UTF-8 -classpath "C:\Users\gksdy\OneDrive\바탕 화면\ExampleProject\out\production\ExampleProject" Main
main
Thread-0

 

 

스레드의 동기화

 

프로세스는 자원, 데이터, 그리고 스레드로 구성된다. 프로세스는 스레드가 운영 체제로부터 자원을 할당받아 소스 코드를 실행하여 데이터를 처리한다.

 

이때, 싱글 스레드 프로세스는 데이터에 단 하나의 스레드만 접근하므로, 문제 될 사항이 없다.

그러나, 멀티 스레드 프로세스의 경우, 두 스레드가 같은 데이터를 공유하게 되어 문제가 발생할 수 있다.

그런 문제가 발생하지 않게 하는 것을 스레드 동기화라고 한다.

 

예시를 통해 구체적으로 어떤 문제가 발생하는지 확인해보자.

코드 예시를 보기 전에 먼저 생소할 만한 문법 요소를 간단하게 설명한다.

  • try { Thread.sleep(1000); } catch (Exception error) {}
    • Thread.sleep(1000);
      • 스레드를 일시 정지시키는 메서드입니다. 이에 대해서는 이어지는 콘텐츠 [스레드의 상태와 상태 제어]에서 학습합니다. 참고로, 어떤 스레드가 일시 정지되면, 대기열에서 기다리고 있던 다른 스레드가 실행됩니다.
      • 또한, Thread.sleep()은 반드시 try … catch문의 try 블록 내에 작성해주어야 합니다.
      • 여기에서는 간단하게, 스레드의 동작을 1초 동안 멈추는 코드라고 이해해 주세요.
    • try { … } catch ( ~ ) { … }
      • try … catch문은 예외 처리에 사용되는 문법입니다.
      • 쉽게 설명하자면, try의 블록 내의 코드를 실행하다가 예외 또는 에러가 발생하면 catch문의 블록에 해당하는 내용을 실행하라는 의미가 됩니다.
      • Thread.sleep(1000);의 동작을 위해 형식적으로 사용한 문법 요소이니, 여기에서는 큰 의미를 두지 않고 그냥 넘어가셔도 됩니다.
public class ThreadExample3 {
    public static void main(String[] args) {

        Runnable threadTask3 = new ThreadTask3();
        Thread thread3_1 = new Thread(threadTask3);
        Thread thread3_2 = new Thread(threadTask3);

        thread3_1.setName("김코딩");
        thread3_2.setName("박자바");

        thread3_1.start();
        thread3_2.start();
    }
}

class Account {

    // 잔액을 나타내는 변수
    private int balance = 1000;

    public int getBalance() {
        return balance;
    }
		
    // 인출 성공 시 true, 실패 시 false 반환
    public boolean withdraw(int money) {

        // 인출 가능 여부 판단 : 잔액이 인출하고자 하는 금액보다 같거나 많아야 합니다.
        if (balance >= money) {

            // if문의 실행부에 진입하자마자 해당 스레드를 일시 정지 시키고, 
            // 다른 스레드에게 제어권을 강제로 넘깁니다.
            // 일부러 문제 상황을 발생시키기 위해 추가한 코드입니다.
            try { Thread.sleep(1000); } catch (Exception error) {}

            // 잔액에서 인출금을 깎아 새로운 잔액을 기록합니다.
            balance -= money;

            return true;
        }
        return false;
    }
}

class ThreadTask3 implements Runnable {
    Account account = new Account();

    public void run() {
        while (account.getBalance() > 0) {

            // 100 ~ 300원의 인출금을 랜덤으로 정합니다. 
            int money = (int)(Math.random() * 3 + 1) * 100;

            // withdraw를 실행시키는 동시에 인출 성공 여부를 변수에 할당합니다. 
            boolean denied = !account.withdraw(money);

            // 인출 결과 확인
            // 만약, withraw가 false를 리턴하였다면, 즉 인출에 실패했다면,
            // 해당 내역에 -> DENIED를 출력합니다. 
            System.out.println(String.format("Withdraw %d₩ By %s. Balance : %d %s",
                    money, Thread.currentThread().getName(), account.getBalance(), denied ? "-> DENIED" : "")
            );
        }
    }
}
//출력값
"C:\Program Files\Zulu\zulu-11\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\lib\idea_rt.jar=51541:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1\bin" -Dfile.encoding=UTF-8 -classpath "C:\Users\gksdy\OneDrive\바탕 화면\ExampleProject\out\production\ExampleProject" Main
Withdraw 300₩ By 김코딩. Balance : 400 
Withdraw 300₩ By 박자바. Balance : 400 
Withdraw 200₩ By 김코딩. Balance : -100 
Withdraw 300₩ By 박자바. Balance : -100 

Process finished with exit code 0

 

위 코드 예시는 두 개의 작업 스레드를 생성하여 1,000원의 잔액을 가진 계좌로부터 100~300원을 인출하고, 인출금과 잔액을 출력하는 코드다.

 

위 코드를 실행하면 두 개의 작업 스레드가 생성되며, 이 작업 스레드는 Account 객체를 공유하게 된다.

 

출력 결과를 보면, 일단 인출금과 잔액이 제대로 출력되지 못하고 있다.

출력 결과 첫째 줄에서, 김코딩에 의해 300원이 인출되었는데 잔액이 700원이 아니라, 400원이라고 출력되고 있다.

 

이는 두 스레드 간에 객체가 공유되기 때문에 발생하는 오류로, 값이 왜 이렇게 출력되었는지 추측하기가 어렵다.

 

또한, withdraw()에서 잔액이 인출하고자 하는 금액보다 많은 경우에만 인출이 가능하도록 코드를 작성해 두었음에도 불구하고(if (balance >= money) ~), 마치 조건문이 무시된 것처럼 음수의 잔액이 발생하는 것을 확인할 수 있다.

 

여기에서 음수의 잔액이 발생하는 이유는 간단하다. 두 스레드가 하나의 Account 객체를 공유하는 상황에서, 한 스레드가 if 문의 조건식을 true로 평가하여 if문의 실행부로 코드의 흐름이 이동하는 시점에 다른 스레드가 끼어들어 balance를 인출했기 때문이다.

 

그리고, 알 수 없는 원인에 의해 인출에 실패한 경우에 -> DENIED가 제대로 출력되지 않는 문제도 발생하고 있다.

 

이러한 상황이 발생하지 않게 하는 것을 바로 스레드 동기화라고 한다. 스레드 동기화를 적용하면 위의 코드 예시에서 발생하는 문제를 해결할 수 있다.

 

 

임계 영역(Critical section)과 락(Lock)

 

임계 영역은 오로지 하나의 스레드만 코드를 실행할 수 있는 코드 영역을 의미하며, 락은 임계 영역을 포함하고 있는 객체에 접근할 수 있는 권한을 의미한다.

 

즉, 임계 영역으로 설정된 객체가 다른 스레드에 의해 작업이 이루어지고 있지 않을 때, 임의의 스레드 A는 해당 객체에 대한 락을 획득하여 임계 영역 내의 코드를 실행할 수 있다.

 

이때, 스레드 A가 임계 영역 내의 코드를 실행 중일 때에는 다른 스레드들은 락이 없으므로 이 객체의 임계 영역 내의 코드를 실행할 수 없다.

 

잠시 뒤 스레드 A가 임계 영역 내의 코드를 모두 실행하면 락을 반납합니다. 이때부터는 다른 스레드 중 하나가 락을 획득하여 임계 영역 내의 코드를 실행할 수 있다.

 

위의 예제에서 우리에게 필요했던 것은 두 스레드가 동시에 실행하면 안 되는 영역을 설정하는 것이다.

즉, withraw() 메서드를 두 스레드가 동시에 실행하지 못하게 해야 한다.

 

특정 코드 구간을 임계 영역으로 설정할 때는 synchronized라는 키워드를 사용한다.

synchronized 키워드는 두 가지 방법으로 사용할 수 있다.

 

 

1. 메서드 전체를 임계 영역으로 지정하기

 

메서드의 반환 타입 좌측에 synchronized 키워드를 작성하면 메서드 전체를 임계 영역으로 설정할 수 있다.

이렇게 메서드 전체를 임계 영역으로 지정하면 메서드가 호출되었을 때, 메서드를 실행할 스레드는 메서드가 포함된 객체의 락을 얻는다.

 

즉, withdraw()가 호출되면, withdraw()를 실행하는 스레드는 withdraw()가 포함된 객체의 락을 얻으며, 해당 스레드가 락을 반납하기 이전에 다른 스레드는 해당 메서드의 코드를 실행하지 못하게 된다.

class Account {
	...
	public synchronized boolean withdraw(int money) {
	    if (balance >= money) {
	        try { Thread.sleep(1000); } catch (Exception error) {}
	        balance -= money;
	        return true;
	    }
	    return false;
	}
}

 

 

2. 특정한 영역을 임계 영역으로 지정하기

 

특정 영역을 임계 영역으로 지정하려면 아래와 같이 synchronized 키워드와 함께 소괄호(()) 안에 해당 영역이 포함된 객체의 참조를 넣고, 중괄호({})로 블록을 열어, 블록 내에 코드를 작성하면 된다.

 

이 경우에도 마찬가지로, 임계 영역으로 설정한 블록의 코드로 코드 실행 흐름이 진입할 때, 해당 코드를 실행하고 있는 스레드가 this에 해당하는 객체의 락을 얻고, 배타적으로 임계 영역 내의 코드를 실행한다.

class Account {
	...
	public boolean withdraw(int money) {
			synchronized (this) {
			    if (balance >= money) {
			        try { Thread.sleep(1000); } catch (Exception error) {}
			        balance -= money;
			        return true;
			    }
			    return false;
			}
	}
}

 

이제 위의 문제가 발생했던 코드를 개선 할 차례다.

 

바로 위의 두 가지 방법 중 하나를 선택해서 적용하자.

두가지 방법 중 메서드 전체를 임계 영역으로 설정하는 방법을 사용하면 아래와 같이 withdraw()의 반환 타입 앞에 synchronized를 붙여 주면 된다. 인출금이 올바르게 출력되며, 잔액이 음수인 경우에도 올바른 값이 출력된다.

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

        Runnable threadTask3 = new ThreadTask3();
        Thread thread3_1 = new Thread(threadTask3);
        Thread thread3_2 = new Thread(threadTask3);

        thread3_1.setName("김코딩");
        thread3_2.setName("박자바");

        thread3_1.start();
        thread3_2.start();
    }
}

class Account {
    private int balance = 1000;

    public int getBalance() {
        return balance;
    }

    public synchronized boolean withdraw(int money) {
        if (balance >= money) {
            try {
                Thread.sleep(1000);
            } catch (Exception error) {
            }
            balance -= money;
            return true;
        }
        return false;
    }
}

class ThreadTask3 implements Runnable {
    Account account = new Account();

    public void run() {
        while (account.getBalance() > 0) {
            int money = (int)(Math.random() * 3 + 1) * 100;
            boolean denied = !account.withdraw(money);
            System.out.println(String.format("Withdraw %d₩ By %s. Balance : %d %s",
                    money, Thread.currentThread().getName(), account.getBalance(), denied ? "-> DENIED" : "")
            );
        }
    }
}
//출력값
Withdraw 200₩ By 김코딩. Balance : 800 
Withdraw 100₩ By 박자바. Balance : 700 
Withdraw 200₩ By 김코딩. Balance : 500 
Withdraw 300₩ By 박자바. Balance : 200 
Withdraw 200₩ By 김코딩. Balance : 0 
Withdraw 100₩ By 박자바. Balance : 0 -> DENIED

Process finished with exit code 0

 

 

 

자바 가상 머신(Java Virtual Machine)

 

 

JVM 이란?

JVM(Java Virtual Machine)은 자바 프로그램을 실행시키는 도구이다.

즉, JVM은 자바로 작성한 소스 코드를 해석해 실행하는 별도의 프로그램이다.

 

자바는 운영체제로부터 독립적이다. 이러한 자바의 독립성은 JVM을 통해 구현되는데, 이유는 다음과 같다.

 

프로그램이 실행되기 위해서는 CPU, 메모리, 각종 입출력 장치 등과 같은 컴퓨터 자원을 프로그램이 할당받아야 한다.

 

프로그램이 자신이 필요한 컴퓨터 자원을 운영체제에게 주문하면, 운영체제는 가용한 자원을 확인한 다음, 프로그램이 실행되는 데에 필요한 컴퓨터 자원을 프로그램에게 할당해 준다.

 

이때, 프로그램이 운영체제에게 필요한 컴퓨터 자원을 요청하는 방식이 운영체제마다 다른데, 이것이 바로 프로그래밍 언어가 운영체제에 대해 종속성을 가지게 되는 이유다.

 

하지만 자바는 JVM을 매개해서 운영체제와 소통한다. JVM이 자바 프로그램과 운영체제 사이에서 일종의 통역가 역할을 수행한다.

 

JVM은 각 운영체제에 적합한 버전이 존재한다. Windows용 JVM, Mac OS용 JVM, Linux용 JVM이 따로 존재한다.

운영체제에 맞게 JVM이 개발되어 있으며, JVM은 자바 소스 코드를 운영 체제에 맞게 변환해 실행시켜 준다.

이것이 자바가 운영체제로부터 독립적으로 동작할 수 있는 이유다.

 

 

JVM 구조

 

JVM 구조

JVM의 내부 구조는 위 그림과 같다.

위 그림을 통해 자바로 소스 코드를 작성하고 실행하면 어떤 일이 일어나는지 이해해 보자.

 

자바로 소스 코드를 작성하고 실행하면, 먼저 컴파일러가 실행되면서 컴파일이 진행된다.

컴파일의 결과로 .java 확장자를 가졌던 자바 소스 코드가 .class 확장자를 가진 바이트 코드 파일로 변환된다.

 

이후, JVM은 운영 체제로부터 소스 코드 실행에 필요한 메모리를 할당받는다. 그것이 바로 위 그림 상의 런타임 데이터 영역(Rumtime Data Area)이다.

 

그다음에는 클래스 로더(Class Loader)가 바이트 코드 파일을 JVM 내부로 불러들여 런타임 데이터 영역에 적재시킨다.

자바 소스 코드를 메모리에 로드시키는 것이다.

 

로드가 완료되면 이제 실행 엔진(Execution Engine)이 런타임 데이터 영역에 적재된 바이트 코드를 실행시킨다.

이때, 실행 엔진은 두 가지 방식으로 바이트 코드를 실행시킨다.

  1. 인터프리터(Interpreter)를 통해 코드를 한 줄씩 기계어로 번역하고 실행시키기
  2. JIT Compiler(Just-In-Time Compiler)를 통해 바이트 코드 전체를 기계어로 번역하고 실행시키기

 

실행 엔진은 기본적으로 1번의 방법을 통해 바이트 코드를 실행시키다가, 특정 바이트 코드가 자주 실행되면 해당 바이트 코드를 JIT Compiler를 통해 실행시킨다.

 

즉, 중복적으로 어떤 바이트 코드가 등장할 때, 인터프리터는 매번 해당 바이트 코드를 해석하고 실행하지만, JIT 컴파일러가 동작하면 한 번에 바이트 코드를 해석하고 실행시킨다.

 

 

Stack과 Heap

 

JVM 메모리 구조

JVM 메모리 구조

JVM에 Java 프로그램이 로드되어 실행될 때 특정 값 및 바이트코드, 객체, 변수 등과 같은 데이터들이 메모리에 저장되어야한다. 런타임 데이터 영역이 바로 이러한 정보를 담는 메모리 영역이며, 크게 5가지 영역으로 구분되어 있다.

이 중 2가지 Heap 과 Stack 을 살펴보자.

 

 

Stack 영역이란?

 

스택은 일종의 자료구조다. 자료구조는 프로그램이 데이터를 저장하는 방식을 의미한다.

“프로그램이 데이터를 저장하는 방식은 여러 가지가 있는데, 그 저장 방식 중의 하나가 스택이다" 정도로 이해하면 된다.

 

이러한 스택은 흔히 LIFO라는 키워드로 설명된다. LIFO는 “Last In First Out”의 약자로, 직역하면 마지막에 들어간 데이터가 가장 먼저 나온다는 의미다. 프링글스로 예를 들어 스택을 이해해보자.

 

stack 과 프링글스

프링글스의 포장 방식이 완벽한 스택의 형태를 취하고 있다. 프링글스를 열고 감자칩을 꺼낼 때, 맨 아래에 있는 칩을 먼저 꺼낼 수 없다. 밑면을 뜯지 않는 이상, 일반적인 방법으로는 불가능하다.

 

이처럼 맨 마지막에 들어온 데이터가 가장 먼저 나가는 자료 구조를 스택이라고 한다. LIFO는 이러한 스택의 데이터 입출력 순서를 나타내는 원칙이다.

 

그렇다면 JVM안에서 Stack은 어떻게 작동할까?

 

메서드가 호출되면 그 메서드를 위한 공간인 Method Frame이 생성된다. 메서드 내부에서 사용하는 다양한 값들이 있는데 참조변수, 매개변수, 지역변수, 리턴값 및 연산 시 일어나는 값들이 임시로 저장된다.

 

이런 Method Frame이 Stack에 호출되는 순서대로 쌓이게 되는데, Method의 동작이 완료되면 역순으로 제거된다.

stack 작동 순서

이 영역의 동작 원리를 더 자세히 이해하려면 Heap영역에 대해 이해해야 한다.

 

 

Heap 영역이란?

 

VM에는 단 하나의 Heap 영역이 존재한다. JVM이 작동되면 이 영역은 자동 생성된다. 그리고 이 영역 안에 객체나 인스턴스 변수, 배열이 저장된다.

 

앞서 인스턴스를 행성하는 방법을 주제로 학습했다.

Person person = new Person()

위의 예시에서 new Person()이 실행되면 Heap 영역에 인스턴스가 생성되며, 인스턴스가 생성된 위치의 주소값을 person에게 할당해 주는데, 이 person은 Stack 영역에 선언된 변수이다.

 

우리가 객체를 다룬다는 것은 Stack 영역에 저장되어 있는 참조 변수를 통해 Heap 영역에 존재하는 객체를 다룬다는 의미가 된다.정리하자면, Heap 영역은 실제 객체의 값이 저장되는 공간이다.

 

 

 

Garbage Collection

 

Garbage Collection이란?

자바에서는 가비지 컬렉션이라는 메모리를 자동으로 관리하는 프로세스가 포함되어 있다. 가비지 컬렉션은 프로그램에서 더 이상 사용하지 않는 객체를 찾아 삭제하거나 제거하여 메모리를 확보하는 것을 의미한다.

 

아래 코드 예시를 보자.

Person person = new Person();
person.setName("김코딩");
person = null; 
// 가비지 발생
person = new Person(); 
person.setName("박해커");

위의 코드 예시 첫째 줄에서 참조 변수 person은 Person 클래스의 인스턴스의 주소값을 할당받고, 이어서 “김코딩”이라는 문자열이 person이 가리키는 인스턴스의 name이라는 속성에 할당된다.

 

그런데, 세 번째 줄에서 참조 변수 person에 null이 할당됨으로써, 기존에 person이 가리키던 인스턴스와 참조변수 person 간의 연결이 끊어졌다.

프로그램이 실행 중일 때 이처럼 아무도 인스턴스를 참조하고 있지 않다면, 더 이상 메모리에 person이 가리키던 인스턴스가 존재해야 할 이유가 없다.

 

가비지 컬렉터는 이렇게 아무한테도 참조되고 있지 않은 객체 및 변수들을 검색하여 메모리에서 점유를 해제하며, 그럼으로써 메모리 공간을 확보하여 효율적으로 메모리를 사용할 수 있게 해준다.

 

 

Garbage Collection 동작 방식

 

가비지 컬렉션의 동작 방식을 이해하려면 앞서 배운 Heap 메모리 영역에 대한 이해가 필요하다.

JVM의 Heap 영역은 객체는 대부분 일회성이며, 메모리에 남아 있는 기간이 대부분 짧다는 전제로 설계되어 있다.

그러므로 객체가 얼마나 살아있냐에 따라서 Heap 영역 안에서도 영역을 나누게 되는데 Young, Old영역 이렇게 2가지로 나뉜다.

Young 영역에서는 새롭게 생성된 객체가 할당되는 곳이고 여기에는 많은 객체가 생성되었다 사라지는 것을 반복한다.

이 영역에서 활동하는 가비지 컬렉터를 Minor GC라고 부른다.

 

Old 영역에서는 Young영역에서 상태를 유지하고 살아남은 객체들이 복사되는 곳으로 보통 Young 영역보다 크게 할당되고 크기가 큰 만큼 가비지는 적게 발생한다.

이 영역에서 활동하는 가비지 컬렉터를 Major GC라고 부른다.

 

Young 영역과 Old 영역은 서로 다른 메모리 구조로 되어 있으므로, 세부적인 동작 방식은 다르지만, 기본적으로 가비지 컬렉션이 실행될 때는 다음의 2가지 단계를 따른다.

 

 

1. Stop The World

Stop The World는 가비지 컬렉션을 실행시키기 위해 JVM이 애플리케이션의 실행을 멈추는 작업이다.

가비지 컬렉션이 실행될 때 가비지 컬렉션을 실행하는 스레드를 제외한 모든 스레드의 작업은 중단되고, 가비지 정리가 완료되면 재개된다.

 

 

2. Mark and Sweep

Mark는 사용되는 메모리와 사용하지 않는 메모리를 식별하는 작업을 의미하며, Sweep은 Mark단계에서 사용되지 않음으로 식별된 메모리를 해제하는 작업을 의미한다.

 

즉, Stop The World를 통해 모든 작업이 중단되면, 가비지 컬렉션이 모든 변수와 객체를 탐색해서 각각 어떤 객체를 참고하고 있는지 확인한다.

 

이후, 사용되고 있는 메모리를 식별해서(Mark) 사용되지 않는 메모리는 제거(Sweep)하는 과정을 진행한다.

728x90