[Java] 자바 문법 종합반 5주차

Yuri·2025년 1월 6일

Java

목록 보기
13/13
post-thumbnail

쓰레드

프로세스 / 쓰레드

  • 프로세스 : 운영체제로부터 자원을 할당받는 작업의 단위
    • 프로세스는 "실행 중인 프로그램"을 의미
    • OS위에서 실행되는 모든 프로그램 → OS가 만들어준 프로세스에서 실행
    • 프로세스 구조 : OS가 프로그램 실행을 위한 프로세스를 할당해 줄 때, 프로세스 안에 프로그램의 Code, Data, 메모리영역(Stack, Heap)을 함께 할당해준다.
    1. Code는 Java main 메소드와 같은 코드
    2. Data는 프로그램이 실행 중 저장 할 수 있는 저장공간 (전역변수, 정적변수(static), 배열 등 초기화된 데이터를 저장하는 공간)
    3. Memory(메모리 영역)
      • Stack: 지역변수, 매개변수 리턴 변수를 저장하는 공간
      • Heap: 프로그램이 동적으로 필요한 변수를 저장하는 공간 (new())
  • 쓰레드 : 프로세스가 할당받은 자원을 이용하는 실행의 단위(=일꾼)
    • 쓰레드의 생성: 작업중인 프로그램에서 실행요청이 들어오면 쓰레드(일꾼)을 만들어 명령을 처리하도록 한다.
    • 쓰레드의 자원
      • 프로세스 안에는 여러 쓰레드(일꾼)들이 있고, 쓰레드들은 실행을 위한 프로세스 내 주소공간이나 메모리공간(Heap)을 공유받는다.
      • 추가로, 쓰레드(일꾼)들은 각각 명령처리를 위한 자신만의 메모리공간(Stack)도 할당받는다.

  • Java 쓰레드
    일반 쓰레드와 동일하며 JVM 프로세스 안에서 실행되는 쓰레드를 말한다
    - Java 프로그램을 실행하면 JVM 프로세스 위에서 실행된다.
    - Java 프로그램 쓰레드는 Java Main 쓰레드부터 실행되며 JVM에 의해 실행된다.

멀티 쓰레드

📌 Java는 메인 쓰레드가 main() 메서드를 실행시키면서 시작된다.

  • 메인 쓰레드는 필요에 따라서 작업 쓰레드들을 생성해서 병렬로 코드를 실행시킬 수 있다.
  • 즉, Java는 멀티 쓰레드를 지원한다.

싱글 쓰레드

프로세스 안에서 하나의 쓰레드만 실행되는 것을 말한다.

  • Java 프로그램의 경우 main() 메서드(=메인 쓰레드)만 실행시켰을 때 이것을 싱글 쓰레드라고 한다.
  • JVM의 메인 쓰레드가 종료되면, JVM도 같이 종료된다.

멀티 쓰레드

프로세스 안에서 여러 개의 쓰레드가 실행되는 것을 말한다.

  • 하나의 프로세스는 여러 개의 실행 단위(쓰레드)를 가질 수 있으며 이 쓰레드들은 프로세스의 자원을 공유한다.
  • Java 프로그램은 메인 쓰레드 외에 다른 작업 쓰레드들을 생성하여 여러 개의 실행 흐름을 만들 수 있다.

▶︎ 멀티 쓰레드의 장점

  • 여러 개의 쓰레드(실행 흐름)을 통해 여러 개의 작업을 동시에 할 수 있어서 성능이 좋아진다.
  • 스택을 제외한 모든 영역에서 메모리를 공유하기 때문에 자원을 보다 효율적으로 사용할 수 있다.
  • 응답 쓰레드와 작업 쓰레드를 분리하여 빠르게 응답을 줄 수 있다. (비동기)

▶︎ 멀티 쓰레드의 단점

  • 동기화 문제가 발생할 수 있다.
    • 프로세스의 자원을 공유하면서 작업을 처리하기 때문에 자원을 서로 사용하려고 하는 경우 충돌이 발생
  • 교착 상태(데드락, Dead-Lock)이 발생할 수 있다.
    • 둘 이상의 쓰레드가 서로의 자원을 원하는 상태가 되었을 때 서로 작업이 종료되기만을 기다리며 작업을 더 이상 진행하지 못하게 되는 상태를 의미

Thread 와 Runnable

Thread

Java에서 제공하는 Thread 클래스를 상속받아 쓰레드를 구현한다.
▶︎ TestThread.java

package week05.thread;

/**
 * 1. Thread Class를 이용하는 것(상속)
 */
public class TestThread extends Thread {
    @Override
    public void run() {
        // 실제 우리가 쓰레드에서 수행할 작업
        System.out.println("테스트입니다!!");
        for (int i = 0; i < 100; i++) {
            System.out.print("*");
        }
    }
}

Runnable

Java에서 제공하는 Runnable 인터페이스를 사용하여 쓰레드를 구현한다.
▶︎ TestRunnable.java

package week05.thread;

/**
 * 2. Runnable interface를 구현
 */
public class TestRunnable implements Runnable {
    @Override
    public void run() {
        // 쓰레드에서 수행할 작업 정의!
        for (int i = 0; i < 100; i++) {
            System.out.print("$");
        }
    }
}

🤔 Thread 보다 Runnable 인터페이스를 이용하여 쓰레드를 구현하는 이유?
클래스와 인터페이스의 차이 (다중상속을 지원하지 않음)
Thread를 상속받아 처리하는 방법은 확장성이 매우 떨어진다. 반대로 Runnable은 인터페이스이기 때문에 다른 필요한 클래스를 상속받을 수 있다.
→ Runnable이 Thread보다 확장성에 유리하다.

▶︎ Main.java

package week05.thread;

public class Main {
    public static void main(String[] args) {
        // 1. Thread Class 를 상속 -> run() 오버라이딩
        TestThread threadExtends = new TestThread();
        threadExtends.start(); // run()에 작성한 작업을 start()로 실행시킬 수 있다.

        // 2. Runnable 인터페이스 implements -> run() 구현
        Runnable run = new TestRunnable();
        Thread threadRunnable = new Thread(run);

        threadRunnable.start();

        // 3. task 익명함수 객체 생성 (lambda)
        Runnable task = () -> {
            int sum = 0;
            for (int i=0; i<50; i++) {
                sum += i;
                System.out.println(sum);
            }
            System.out.println(Thread.currentThread().getName() + " 최종 합 : " + sum);
        };

        Thread thread1 = new Thread(task);
        thread1.setName("thread1");
        Thread thread2 = new Thread(task);
        thread2.setName("thread2");

        thread1.start();
        thread2.start();
    }
}

싱글 쓰레드 실습

package week05.thread.single;

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            System.out.println("2번 => " + Thread.currentThread().getName());
            for (int i = 0; i < 100; i++) {
                System.out.print("$");
            }
        };

        System.out.println("1번 => " + Thread.currentThread().getName());
        Thread thread1 = new Thread(task);
        thread1.setName("thread1");

        thread1.start();
    }
}

멀티 쓰레드 실습

package week05.thread.multi;

public class Main {
    public static void main(String[] args) {
        // 걸리는 시간이나, 동작을 예측할 수 없다.
        
        // 1st
        Runnable task = () -> {
            System.out.println("실행: " + Thread.currentThread().getName());
            for (int i=0; i<100; i++) {
                System.out.print("$");
            }
        };

        // 2nd
        Runnable task2 = () -> {
            System.out.println("실행: " + Thread.currentThread().getName());
            for (int i=0; i<100; i++) {
                System.out.print("*");
            }
        };

        Thread thread1 = new Thread(task);
        thread1.setName("thread1");
        Thread thread2 = new Thread(task2);
        thread2.setName("thread2");

        thread1.start();
        thread2.start();
    }
}

  • $와 * 의 순서가 일정하지 않게 출력되는 모습
  • 즉, 2개의 쓰레드는 서로 번갈아가면서 수행된다.
  • 두 쓰레드의 실행순서나 걸리는 시간은 OS의 스케줄러가 처리하기 때문에 알 수 없다.

데몬 쓰레드와 사용자 쓰레드

데몬 쓰레드(Daemon)

보이지 않는 곳(background)에서 실행되는 낮은 우선순위를 가진 쓰레드를 말한다.

  • 보조적인 역할을 담당하며 대표적인 데몬 쓰레드로는 메모리 영역을 정리해 주는 가비지 컬렉터(GC)가 있다.

▶︎ 데몬 쓰레드 설정 방법

package week05.thread.daemon;

public class Main {
    public static void main(String[] args) {
        Runnable daemon = () -> {
            for (int i = 0; i < 1000000; i++) {
                System.out.println(i+"번째 daemon");
            }
        };

        // 우선순위가 낮다 => 상대적으로 다른 쓰레드에 비해 리소스를 적게 할당받는다.
        Thread thread = new Thread(daemon);
        thread.setDaemon(true); // 쓰레드를 데몬 쓰레드로 설정함

        thread.start();

        for (int i = 0; i < 100; i++) {
            System.out.println(i + "번째 task");
        }
    }
}
  • 메인 쓰레드는 데몬 쓰레드가 끝날때까지 기다려주지 않고 메인 메서드가 끝나면 종료된다.
  • 반복문에 의해 10만번 출력되어야할 "daemon"이 180번만 출력되고 프로그램이 종료된다.

사용자 쓰레드

보이는 곳(foreground)에서 실행되는 높은 우선순위를 가진 쓰레드

  • 프로그램의 기능을 담당하며 대표적인 사용자 쓰레드로는 메인 쓰레드가 있다.

⚠️ JVM은 사용자 쓰레드의 작업이 끝나면 데몬 쓰레드도 자동으로 종료시켜 버린다.

쓰레드 우선순위와 쓰레드 그룹

📌 쓰레드 작업의 중요도에 따라서 쓰레드의 우선순위를 부여할 수 있다.

  • 작업의 중요도가 높을 때 우선순위를 높게 지정하면 더 많은 작업시간을 부여받아 빠르게 처리될 수 있다.
  • 쓰레드는 생성될 때 우선순위가 정해진다.
  • 우선순위는 아래와 같이 3가지 (최대/최소/보통) 우선순위로 나뉜다.
    • 최대 우선순위(MAX_PRIORITY) = 10
    • 최소 우선순위(MIN_PRIORITY) = 1
    • 보통 우선순위(NROM_PRIORITY) = 5
      • 기본값이 보통 우선순위이다.
    • 더 자세하게 나눈다면 1~10 사이의 숫자로 지정 가능하다. (숫자가 높을 수록 우선순위가 높다.)
    • 이 우선순위의 범위는 OS가 아니라 JVM에서 설정한 우선순위이다.
  • 쓰레드 우선순위는 setPriority() 메서드로 설정할 수 있다.

▶︎ 쓰레드의 우선순위 실습

package week05.thread.priority;

public class Main {
    public static void main(String[] args) {
        Runnable task1 = () -> {
            for (int i=0; i<100; i++) {
                System.out.print("$");
            }
            System.out.println();
            System.out.println("task1 끝!");
        };

        Runnable task2 = () -> {
            for (int i=0; i<100; i++) {
                System.out.print("*");
            }
            System.out.println();
            System.out.println("task2 끝!");
        };

        Thread thread1 = new Thread(task1);
        thread1.setPriority(8);
        int thread1Priority = thread1.getPriority();
        System.out.println("thread1 priority: " + thread1Priority);

        Thread thread2 = new Thread(task2);
        thread2.setPriority(2);
        int thread2Priority = thread2.getPriority();
        System.out.println("thread2 priority: " + thread2Priority);

        thread1.start(); // 작업시간을 더 많이 할당받아 더 빨리 끝날 가능성이 높다.
        thread2.start();
    }
}


→ 실제로는 OS의 스케줄러에 의한 변수가 많기 때문에 무조건 우선순위가 높은 작업이 먼저 끝난다고 보장할 수 없다.

쓰레드 그룹

서로 관련이 있는 쓰레드들을 그룹으로 묶어서 다룰 수 있다.

  • 쓰레드들은 기본적으로 그룹에 포함되어 있다.
    • JVM이 시작되면 system 그룹이 생성되고 쓰레드들은 기본적으로 system 그룹에 포함된다.
  • 메인 쓰레드는 system 그룹 하위에 있는 main 그룹에 포함된다.
  • 모든 쓰레드들은 반드시 하나의 그룹에 포함되어 있어야 한다.
    • 쓰레드 그룹을 지정받지 못한 쓰레드는 자신을 생성한 부모 쓰레드의 그룹과 우선순위를 상속받게 되는데 우리가 생성하는 쓰레드들은 main 쓰레드의 하위에 포함된다.
    • 따라서 쓰레드 그룹을 지정하지 않으면 해당 쓰레드는 자동으로 main 그룹에 포함된다.

package week05.thread.group;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            while (!Thread.currentThread().isInterrupted()) {
                // main의 interrupt()가 호출되기 전까지 계속 수행
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    break;
                }
            }
            System.out.println(Thread.currentThread().getName() + " Interrupted");
        };

        // ThreadGroup 클래스로 객체를 만듦
        ThreadGroup group1 = new ThreadGroup("Group1");

        // Thread 객체 생성 시 첫번째 매개변수로 넣어준다.
        // Thread(ThreadGroup group, Runnable target, String name);
        Thread thread1 = new Thread(group1, task, "Thread 1");
        Thread thread2 = new Thread(group1, task, "Thread 2");

        // Thread가 ThreadGroup 에 할당된 것을 확인할 수 있다.
        System.out.println("Group of Thread1 : " + thread1.getThreadGroup().getName());
        System.out.println("Group of Thread2 : " + thread2.getThreadGroup().getName());

        thread1.start();
        thread2.start();

        try {
            // 현재 쓰레드를 지정된 시간동안 멈추게 합니다.
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // interrupt()는 일시정지 상태인 쓰레드를 실행대기 상태로 만듭니다.
        group1.interrupt();
    }
}
  • thread1, thread2를 Group1에 할당하여 관리
  • 메인 쓰레드에서 group1.interrupt()를 발생시키자 각각의 쓰레드(thread1, thread2)에서 실행중이던 루프가 종료

쓰레드 상태와 제어 → sleep(), interrupt()

쓰레드는 상태가 존재하고 이를 제어할 수 있다.

쓰레드 상태

  • 쓰레드가 객체 생성된 후(new) start() 메서드에 의해 실행대기(Runnable) 상태로 변경되고 실행대기에서 스케줄러에 의해 실행되었다가 실행 대기를 반복하다 모든 작업을 완료하면 종료(TERMINATED)된다.

▶︎ 쓰레드의 상태

상태Enum설명
객체생성NEW쓰레드 객체 생성, 아직 start() 메서드 호출 전의 상태
실행대기RUNNABLE실행 상태로 언제든지 갈 수 있는 상태
일시정지WAITING다른 쓰레드가 통지(notify) 할 때까지 기다리는 상태
일시정지TIMED_WAITING주어진 시간 동안 기다리는 상태
일시정지BLOCKED사용하고자 하는 객체의 Lock이 풀릴 때까지 기다리는 상태
종료TERMINATED쓰레드의 작업이 종료된 상태

▶︎ 쓰레드 제어

sleep / interrupt

▶︎ sleep
sleep(): 현재 쓰레드를 지정된 시간동안 멈추게 한다.

package week05.thread.stat.sleep;

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                // (1) 예외처리 필수
                // - interrupt()를 만나면 다시 실행되기 때문에
                // - InterruptException이 발생할 수 있다. -> try-catch로 예외처리 반드시 필요
                // (2) 특정 쓰레드 지목 불가 : sleep() -> static method
                Thread.sleep(2000); // TIMED_WATING(주어진 시간동안만 기다리는 상태)
                // 객체.메서드();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("task : " + Thread.currentThread().getName());
        };

        Thread thread = new Thread(task, "Thread"); // NEW
        thread.start(); // NEW -> RUNNABLE

        try {
            // 1초가 지나고 나면 runnable 상태로 변하여 다시 실행된다.
            // 특정 스레드를 지정해서 멈추게 하는 것은 불가능하다.
            // Static member 'java.lang.Thread.sleep(long)' accessed via instance reference
            thread.sleep(1000); // -> 인스턴스에서 static 메서드를 호출하고 있음 : 의미없다.
            // Thread.sleep(1000); // sleep을 통해서 메서드를 정지시킬 땐 특정 쓰레드를 지정할 수 없다.
            System.out.println("sleep(1000) : " + Thread.currentThread().getName());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

▶︎ interrupt
interrupt(): 일시정시 상태인 쓰레드를 실행대기 상태로 만든다.

  • sleep() 중 interrupt()를 만나는 경우: InterruptedException 발생
package week05.thread.stat.interrupt;

// - 쓰레드가 'start()' 된 후 동작하다 'interrupt()'를 만나 실행하면 interrupted 상태가 true가 된다.
public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                // sleep 도중 interrupt 발생 시, catch!
                Thread.sleep(1000);
                System.out.println("task - try 안 : " + Thread.currentThread().getName());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("task - try 밖 : " + Thread.currentThread().getName());
        };

        Thread thread = new Thread(task, "Thread"); // New
        thread.start(); // Runnable

        thread.interrupt(); // sleep -> interrupt

        System.out.println("thread.isInterrupted() = " + thread.isInterrupted());
    }
}

  • thread.isInterrupted() : 현재 쓰레드가 interrupted 상태인지 여부 (true: interrupt 상태)
    → 해당 값(boolean)을 분기로 하여 에러 발생 시 처리를 할 수 있다. : catch로 빠지지 않음
public static void main(String[] args) {
        Runnable task = () -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    // sleep 도중 interrupt 발생 시, catch!
                    Thread.sleep(1000);
                    System.out.println("task - try 안 : " + Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    break;
                }
            }
            System.out.println("task - try 밖 : " + Thread.currentThread().getName());
        };

        Thread thread = new Thread(task, "Thread"); // New
        thread.start(); // Runnable

        thread.interrupt(); // sleep -> interrupt

        System.out.println("thread.isInterrupted() = " + thread.isInterrupted());
    }

join / yield / synchronized

▶︎ join
join(): 정해진 시간동안 지정한 쓰레드가 작업하는 것을 기다린다.

  • 시간을 지정하지 않았을 때는 지정한 쓰레드의 작업이 끝날 때까지 기다린다.
  • join() 역시 interrupt()를 만나면 기다리는 것을 멈추기 때문에 InterruptedException이 발생할 수 있다. (예외처리 필수)
package week05.thread.stat.join;

// 정해진 시간동안 지정한 쓰레드가 작업하는 것을 기다립니다.
// - 시간을 지정하지 않았을 때는 지정한 쓰레드가 작업이 끝날 때 까지 기다립니다.
public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        Thread thread = new Thread(task, "thread"); // NEW

        thread.start(); // NEW -> RUNNABLE

        long start = System.currentTimeMillis();

        try {
            // 시간을 지정하지 않았기 때문에 thread가 작업을 끝낼 때까지 main 쓰레드는 기다리게 됩니다.
            thread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // thread의 소요시간이 5000ms 동안 main 쓰레드가 기다리기 때문에 5000 이상이 출력된다.
        System.out.println("소요시간 = " + (System.currentTimeMillis() - start));
    }

}

▶︎ yield
yield(): 남은 시간을 다음 쓰레드에게 양보하고 쓰레드 자신은 실행 대기 상태가 된다.

✏️ yield: "넘겨주다", "양보하다"라는 뜻을 가지고 있다.

package week05.thread.stat.yield;

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                for (int i = 0; i < 10; i++) {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                }
            } catch (InterruptedException e) {
                Thread.yield();
//                e.printStackTrace();
            }
        };

        Thread thread1 = new Thread(task, "thread1"); // NEW
        Thread thread2 = new Thread(task, "thread2"); // NEW

        thread1.start(); // NEW -> RUNNABLE
        thread2.start(); // NEW -> RUNNABLE

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread1.interrupt();
    }
}
  • 메인 쓰레드에서 thread1에 interrupt()를 발생시키기 전(5초)까지 thread1, thread2에서 1초마다 Thread.currentThread().getName() 현재 쓰레드의 이름을 출력, interrupt 발생 후 thread1은 InterruptException 예외로 catch되어 yield()를 통해 가지고 있던 리소스(공유하는 프로세스 자원)을 모두 thread2에 양보한다.

▶︎ synchronized
멀티 쓰레드의 경우 여러 쓰레드가 한 프로세스의 자원을 공유해서 작업하기 때문에 서로에게 영향을 줄 수 있다. → 이로 인한 장애나 버그가 발생할 수 있다. (교착상태)

  • 이러한 일을 방지하기 위해 한 쓰레드가 진행중인 작업을 다른 쓰레드가 침범하지 못하도록 막는 것을 쓰레드 동기화(Synchronized)라고 한다.
  • 동기화를 하려면 다른 쓰레드의 침범을 막아야하는 코드들을 '임계영역'으로 설정하면 된다.
  • 임계영역에는 Lock을 가진 단 하나의 쓰레드만 출입이 가능하다.
    • 즉, 임계영역은 한번에 한 쓰레드만 사용이 가능하다.
  • synchronized를 사용한 동기화
    • 실행할 메서드 또는 실행할 코드 묶음 앞에 synchronized를 붙여서 임계영역을 지정하여 다른 쓰레드의 침범을 막을 수 있다. (침범을 막다. = Lock을 걸다.)
    • 임계영역 지정
      1. 메서드 전체를 임계영역으로 지정한다.
      2. 특정 영역을 임계영역으로 지정한다.
package week05.thread.stat.sync;

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

        Runnable task = () -> {
            while (appleStore.getStoredApple() > 0) {
                appleStore.eatApple();
                System.out.println("남은 사과의 수 = " + appleStore.getStoredApple());
            }
        };

        // 3개의 thread를 한꺼번에 만들어서 start()를 해버림
        // 생성(NEW)과 동시에 start(NEW -> RUNNABLE)
        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
    }
}

class AppleStore {
    private int storedApple = 10;

    public int getStoredApple() {
        return storedApple;
    }

    public void eatApple() {
        synchronized (this) {
            if(storedApple > 0) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                storedApple -= 1;
            }
        }
    }
}

wait / notify

침범을 막은 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, wait()을 호출하여 쓰레드가 Lock을 반납하고 기다리게 할 수 있다.

  • 그럼 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 되고
  • 추후에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서,
  • 작업을 중단했던 쓰레드가 다시 Lock을 얻어 진행할 수 있게 된다.

▶︎ wait()
실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다린다.

▶︎ notify()
해당 객체의 대기실(waiting pool)에 있는 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받는다.
→ 특정 쓰레드를 집어서 통지할 수 없다.

package week05.thread.stat.waitnotify;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class Main {
    public static String[] itemList = {
            "MacBook", "IPhone", "AirPods", "iMac", "Mac mini"
    };

    public static AppleStore appleStore = new AppleStore();

    public static final int MAX_ITEM = 5;

    public static void main(String[] args) {
        // 가게 점원
        Runnable StoreClerk = () -> {
            while (true) {
                // 0부터 4사이의 정수 중, Random한 값 뽑아내기 위함
                int randomItem = (int) (Math.random() * MAX_ITEM);
                // restock : 재고를 넣는 메서드
                appleStore.restock(itemList[randomItem]);

                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                }
            }
        };

        // 고객
        Runnable Customer = () -> {
            while (true) {
                try {
                    Thread.sleep(77);
                } catch (InterruptedException e) {
                }

                int randomItem = (int) (Math.random() * MAX_ITEM);
                // sale : 판매하는 메서드
                appleStore.sale(itemList[randomItem]);
                System.out.println(Thread.currentThread().getName() + " Purchase Item " + itemList[randomItem]);
            }
        };

        new Thread(StoreClerk, "StoreClerk").start();
        new Thread(Customer, "Customer1").start();
        new Thread(Customer, "Customer2").start();
    }

}

class AppleStore {
    private List<String> inventory = new ArrayList<>();

    // 재입고
    public void restock(String item) {
        synchronized (this) {
            while (inventory.size() >= Main.MAX_ITEM) {
                System.out.println(Thread.currentThread().getName() + " Waiting!");
                try {
                    wait(); // 재고가 꽉 차있어서 재입고하지 않고 기다리는 중!
                    // restock()이 wating pool로 들어감.. 물건이 판매되는 순간 sale() -> notify();
                    Thread.sleep(333);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            // 재입고
            inventory.add(item);
            notify(); // 재입고 되었음을 고객에게 알려주기
            System.out.println("Inventory 현황: " + inventory.toString());

        }
    }

    public synchronized void sale(String itemName) {
        while (inventory.size() == 0) {
            System.out.println(Thread.currentThread().getName() + " Waiting 1! itemName: " + itemName);
            try {
                wait(); // 재고가 없기 때문에 고객 대기중
                // sale() 이 wating pool로 들어감.. 재입고 되는 순간 restock() -> notify();
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        while(true) {
            // 고객이 주문한 제품이 있는지 확인
            for(int i = 0; i < inventory.size(); i++) {
                if (itemName.equals(inventory.get(i))) {
                    inventory.remove(itemName);
                    notify(); // 제품 하나 팔렸으니 재입고 하라고 알려주기
                    return; // 메서드 종료
                }
            }

            // 고객이 찾는 제품이 없을 경우
            try {
                System.out.println(Thread.currentThread().getName() + " Waiting 2! itemName: " + itemName);
                wait();
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

⚠️ 병목현상 발생

Lock / Condition

▶︎ Lock
synchronized 블럭으로 동기화하면 자동적으로 Lock이 걸리고 풀리지만, 같은 메서드 내에서만 Lock을 걸 수 있다는 제약이 있다. 이런 제약을 해결하기 위해 Lock 클래스를 사용한다.

  • ReenterantLock
    • 재진입이 가능한 Lock, 가장 일반적인 배타 Lock
    • 특정 조건에서 Lock을 풀고, 나중에 다시 Lock을 얻어 임계영역으로 진입 가능
public class MyClass {
    private Object lock1 = new Object();
    private Object lock2 = new Object();
    
    public void methodA() {
        synchronized (lock1) {
            methodB();
        }
    }
    
    public void methodB() {
        synchronized (lock2) {
            // do something
            methodA();
        }
    }
}
  • methodA에서 이미 lock1을 가지고 있으므로 lock2를 기다리는 상태가 되어 데드락이 발생할 가능성이 있다.

    • 하지만, ReentrantLock을 사용하면, 같은 스레드가 이미 락을 가지고 있더라도 락을 유지하며 계속 실행할 수 있기 때문에 데드락이 발생하지 않는다.
    • 즉, ReentrantLock을 사용하면 코드의 유연성을 높일 수 있다.
  • ReenterantReadWriteLock

    • 읽기를 위한 Lock과 쓰기를 위한 Lock을 따로 제공
    • 읽기에는 공유적이고, 쓰기에는 배타적인 Lock
    • 읽기 Lock이 걸려있으면 다른 쓰레드들도 읽기 Lock을 중복으로 걸고 읽기를 수행할 수 있다(read-only)
    • 읽기 Lock이 걸려있는 상태에서 쓰기 Lock을 거는 것은 허용되지 않는다. (데이터 변경 방지)
  • StampedLock

    • ReenterantReadWriteLock에 낙관적인 Lock의 기능을 추가
      • 낙관적인 Lock: 데이터를 변경하기 전에 락을 걸기 않는 것을 말한다. 낙관적인 락은 데이터 변경을 할 때 충돌이 일어날 가능성이 적은 상황에서 사용
      • 낙관적인 락을 사용하면 읽기와 쓰기 작업 모두가 빠르게 처리된다. 쓰기 작업이 발생했을 때 데이터가 이미 변경된 경우 다시 읽기 작업을 수행하여 새로운 값을 읽어들이고, 변경 작업을 다시 수행함. 이러한 방식으로 쓰기 작업이 빈번하지 않은 경우에는 낙관적인 락을 사용하여 더 빠른 처리가 가능함
    • 낙관적인 읽기 Lock은 쓰기 Lock에 의해 바로 해제 가능하다.
    • 무조건 읽기 Lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기 후 읽기 Lock을 건다.

▶︎ Condition
wait() & notify()의 문제점인 waiting pool 내 쓰레드를 구분하지 못한다는 것을 해결한 것이 Condition이다.

📌 wait()과 notify()는 객체에 대한 모니터링 락(lock)을 이용하여 스레드를 대기시키고 깨웁니다. 그러나 wait()과 notify()는 waiting pool 내에 대기 중인 스레드를 구분하지 못하므로, 특정 조건을 만족하는 스레드만 깨우기가 어렵습니다.

이러한 문제를 해결하기 위해 JDK 5에서는 java.util.concurrent.locks 패키지에서 Condition 인터페이스를 제공합니다. Condition은 waiting pool 내의 스레드를 분리하여 특정 조건이 만족될 때만 깨우도록 할 수 있으며, ReentrantLock 클래스와 함께 사용됩니다. 따라서 Condition을 사용하면 wait()과 notify()의 문제점을 보완할 수 있습니다.

wait() & notify() 대신 Condition의 await() & signal() 을 사용합니다.

package week05.thread.stat.condition;

import java.util.ArrayList;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class Main {
    public static final int MAX_TASK = 5;

    private ReentrantLock lock = new ReentrantLock();

    // lock으로 condition 생성
    private Condition condition1 = lock.newCondition();
    private Condition condition2 = lock.newCondition();

    private ArrayList<String> tasks = new ArrayList<>();

    public void addMethod(String task) {
        lock.lock(); // 임계영역 시작

        try {
            while(tasks.size() >= MAX_TASK) {
                String name = Thread.currentThread().getName();
                System.out.println(name + " is waiting.");
                try {
                    condition1.await(); // wait(); condition1 쓰레드를 기다리게 합니다.
                    Thread.sleep(500);
                } catch (InterruptedException e) {}

            }
            tasks.add(task);
            condition2.signal(); // notify(); 기다리고 있는 condition2를 깨워준다.
            System.out.println("Tasks: " + tasks.toString());
        } finally {
            lock.unlock(); // 임계영역 끝
        }
    }
}

👉 정리하면, wait() 작업에 이름을 부여하여(=condition.await()) 특정 조건이 충족되면 직접 해당 작업을 지정하여 깨울 수 있다 notify() (=condition.signal())


모던 자바(람다, 스트림, Optional)

모던 자바: 자바 8 변경점

📌 자바는 진화하는 언어이며, 가장 큰 진화는 Java 8 에서 이루어졌다.

  • 시장의 상황에 따라서 프로그래밍 언어는 새로운 대안으로 등장하고, 적응해서 살아남거나, 적응하지 못해서 도태되기도 한다.
  • 새로운 요구사항(병렬 처리, 함수형 프로그래밍)이 등장하면서 자바도 끊임없이 변화하고 있다.

▶︎ 함수형 프로그래밍

  • 순수 메서드(함수)
    수학의 함수처럼, 특정한 데이터에 의존하지 않고, 관련 없는 데이터를 변경하지도 않으며, 결과값이 오직 입력값에만 영향을 받는 함수를 순수 함수라고 한다.
  • 순수 메서드가 아닌 메서드
    • 메서드 안에서 제어할 수 없는 내부 필드값에 의해 output 값이 변경됨
    • 메서드 안에서 다른 값(필드)를 변경하고 반환

함수형 프로그래밍은 순수한 함수의 모음으로 바라보고 구현한다.

  • 함수형 프로그래밍의 장점
    • 검증이 쉽다.
    • 성능 최적화가 쉽다.(특정 input에 대한 output이 일정하기 때문에 재사용할 수 있다 - 캐싱)
    • 동시성 문제를 해결하기 쉽다.

▶︎ Java 8에서 추가된 개념들

  1. 함수를 일급 값으로 (함수=객체(변수))
  2. 람다(lambda): 익명 함수
package week05.stream;

import java.util.ArrayList;
import java.util.List;

// 주차장 예제
// 티켓, 파킹머니가 있는 차량 -> 주차 가능
public class LambdaAndStream {
    public static void main(String[] args) {
        // 주차대상 차량
        ArrayList<Car> carsWantToPark = new ArrayList<>();

        // 주차장
        ArrayList<Car> parkingLot = new ArrayList<>();

        // 주말 주차장
        ArrayList<Car> weekendParkingLot = new ArrayList<>();

        // 5개의 car 인스턴스 생성
        Car car1 = new Car("Benz", "Class E", true, 0);
        Car car2 = new Car("BMW", "Series 7", false, 100);
        Car car3 = new Car("BMW", "X9", false, 0);
        Car car4 = new Car("Audi", "A7", true, 0);
        Car car5 = new Car("Hyundai", "Ionic 6", false, 10000);

        carsWantToPark.add(car1);
        carsWantToPark.add(car2);
        carsWantToPark.add(car3);
        carsWantToPark.add(car4);
        carsWantToPark.add(car5);


//        parkingLot.addAll(parkingCarWithTicket(carsWantToPark));
        parkingLot.addAll(parkCars(carsWantToPark, Car::hasTicket));

//        parkingLot.addAll(parkingCarWithMoney(carsWantToPark));
        parkingLot.addAll(parkCars(carsWantToPark, Car::noTicketButMoney));

        // 익명함수 적용
        parkingLot.addAll(parkCars(carsWantToPark, (Car car) -> car.hasParkingTicket() && car.getParkingMoney() > 1000));

        for (Car car : parkingLot) {
            System.out.println("Parked Car : " + car.getCompany() + "-" + car.getModel());
        }
    }

    // 타입 -> (함수형) 인터페이스
    // 인터페이스는 타입 역할을 할 수 있기 때문
    // 함수형 인터페이스: 추상 메서드를 딱 하나만 가지고 있음
//    public exampleMethod(int parameter1, ? parameterFunction) {
//        parameterFunction~~;
//    }

/*
    private static List<Car> parkingCarWithTicket(List<Car> carsWantToPark) {
        ArrayList<Car> cars = new ArrayList<>();

        for (Car car : carsWantToPark) {
            if (car.hasParkingTicket()) {
                cars.add(car);
            }
        }
        return cars;
    }

    private static List<Car> parkingCarWithMoney(List<Car> carsWantToPark) {
        ArrayList<Car> cars = new ArrayList<>();

        for (Car car : carsWantToPark) {
            if (!car.hasParkingTicket() && car.getParkingMoney() > 1000) {
                cars.add(car);
            }
        }
        return cars;
    }
*/

    // 위의 두 메소드를 하나로 : 내부 주요 로직을 함수로 전달받자
    public static List<Car> parkCars(List<Car> carsWantToPark, Predicate<Car> function) {
        List<Car> cars = new ArrayList<>();

        for (Car car : carsWantToPark) {
            // 전달될 함수를 사용하여 구현
            if (function.test(car)) {
                cars.add(car);
            }
        }

        return cars;
    }


}

class Car {
    private final String company; // 자동차 회사
    private final String model; // 자동차 모델

    private final boolean hasParkingTicket;
    private final int parkingMoney;

    public Car(String company, String model, boolean hasParkingTicket, int parkingMoney) {
        this.company = company;
        this.model = model;
        this.hasParkingTicket = hasParkingTicket;
        this.parkingMoney = parkingMoney;
    }

    public String getCompany() {
        return company;
    }

    public String getModel() {
        return model;
    }

    public boolean hasParkingTicket() {
        return hasParkingTicket;
    }

    public int getParkingMoney() {
        return parkingMoney;
    }

    public static boolean hasTicket(Car car) {
        return car.hasParkingTicket;
    }

    public static boolean noTicketButMoney(Car car) {
        return !car.hasParkingTicket && car.getParkingMoney() > 1000;
    }
}

interface Predicate<T> {
    boolean test(T t);
}

▶︎ 스트림 → map, filter

  1. 스트림은 데이터 처리연산을 지원하도록 소스에서 추출된 연속된 요소
  2. 컬렉션이 데이터를 저장하거나 접근하는데 초점을 맞춘 인터페이스라면 스트림은 데이터를 처리하는데 초점을 맞춘 인터페이스
  3. 컬렉션의 반복을 멋지게 처리하는 일종의 기능이자, 멀티스레드 관련 코드도 알아서 병렬로 추가해주는 기능

📌 자료구조(리스트, 맵, 셋 등)의 흐름을 객체로 제공해주고, 그 흐름동안 사용할 수 있는 메서드들을 api로 제공해주는 것

▶︎ 스트림의 특징

  1. 원본의 데이터를 변경하지 않는다.
    • 자바 컬렉션으로부터 스트림(해당 컬렉션의 흐름)을 받아서 한 번 사용함
  2. 일회용이다.
    • 한 번 사용한 스트림은 어디에도 남지 않는다.


Collection 클래스 내부 stream(): 모든 컬렉션을 상속하는 구현체들은 스트림을 반환할 수 있다.

스트림 받아오기 → 스트림 가공하기 → 스트림 결과 만들기

▶︎ 스트림 API
map(), forEach(), filter()

  • map → 모든 요소를 가공해서 반환 : 최종 연산을 수행하여 그 결과값을 사용하는 경우
  • forEach → 단순히 결과가 필요하지 않고 반복적으로 작업만 하려는 경우
  • filter → 조건에 맞는 것만 반환

Null / Optional

Null

런타임 중 NullPointerException 발생

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

        SomeDBClient myDB = new SomeDBClient();
        
        String userId = myDB.findUserIdByUsername("HelloWorldMan");

        System.out.println("HelloWorldMan's user Id is : " + userId);
    }
}

class SomeDBClient {

    public String findUserIdByUsername(String username) {
        // ... db에서 찾아오는 로직
		String data = "DB Connection Result";

        if (data != null) {
            return data;
        } else {
            return null;
        } 
    }
    
}

문제점

  1. 논리적으로도, 환경적으로도 null이 반환될 여지가 있음에도, null이 반환될 수 있음을 명시하지 않음
  2. 메인 함수 쪽에서 사용할 때 null 체크를 하지 않아, 만약 null이 반환된다면 nullPointerException이 발생하게 된다.

개선안

리턴값을 객체로 감싸서 NullPointerException을 방지하자 → 이것을 발전시켜 자바에서 제공하는 것이 java.util.Optional 객체이다.

▶︎ Optional 사용법

  • 값이 null인 Optional 생성
Optional<Car> emptyOptional = Optional.empty();
  • 값이 있는 Optional 생성
Optional<Car> hasDataOptional = Optional.of(new Car());
  • 값이 있을 수도 없을 수도 있는 Optional 생성
Optional<Car> hasDataOptional = Optional.ofNullable(getCarFromDB());
  • Optional 객체 사용하기 (값 받아오기)
Optional<String> carName = getCarNameFromDB();
// orElse()를 통해 값을 받아온다, 파라미터로는 null인 경우 반환할 값을 적는다.
String realCarName = carName.orElse("NoCar");

// 위는 예시, 실제로 사용하는 방법
String carName = getCarNameFromDB().orElse("NoCar");

// orElseGet()이라는 메서드를 사용해서 값을 받아올 수 있습니다.
// 파라미터로는 없는 경우 실행될 함수를 전달합니다.
Car car = getCarNameFromDB().orElseGet(Car::new);

// 값이 없으면, 그 아래 로직을 수행하는데 큰 장애가 되는경우 예외를 발생시킬수도 있습니다.
Car car = getCarNameFromDB()
						.orElseThrow(() -> new CarNotFoundException("NO CAR!")
profile
안녕하세요 :)

0개의 댓글