본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[자바] 주요 개념 정리

finalize 메서드


Java의 자동화된 쓰레기 수집기(garbage collector)는 객체를 삭제하기 전에 finalize() 메서드를 호출합니다. 따라서 객체가 삭제되기 직전에 실행되어야 하는 동작이 있다면 Object 클래스에 정의된 finalize() 메서드를 오버라이드하여 정의할 수 있습니다.



Collection Framework


Java의 컬렉션 프레임워크는 아주 유용합니다. 그 중 가장 유용한 몇 가지를 들어보면 다음과 같습니다.


ArrayList: ArrayList는 동적으로 크기가 조정되는 배열로, 새 원소를 삽입하면 크기가 늘어납니다.

ArrayList<String> myArr = new ArrayList();
 myArr.add("one");
 myArr.add("two");
 System.out.println(myArr.get(0)); // <one> 출력


Vector: Vector는 ArrayList와 비슷하지만 다중 쓰레드 환경에서 안전하도록 동기화된다는 차이가 있습니다. 문법은 거의 동일합니다.

Vector<String> myVect = new Vector<String>();
myVect.add("one");
myVect.add("two");
System.out.println(myVect.get(0));


LinkedList: 이 클래스에 관해 묻는 경우는 별로 없지만, 순환자를 어떻게 사용해야 하는지를 잘 보여주므로 알아두면 좋습니다.

LinkedList<String> myLinkedList = new LinkedList<String>();
myLinkedList.add("two");
myLinkedList.addFirst("one");
Iterator<String> iter = myLinkedList.iterator();
while(iter.hasNext()) {
      System.out.println(iter.next());
}


HashMap: HashMap 컬렉션은 면접이나 실제 상황 가릴 것 없이 광범위하게 사용됩니다. 사용 문법을 아래에 제시했습니다.

HashMap<String, String> map = new HashMap<String, String>();
map.put("one", "uno");
map.put("two", "dos");
System.out.println(map.get("one"));


쓰레드와 락


마이크로소프트나 구글, 아마존과 같은 회사에서는 쓰레드로 알고리즘을 구현하라는 문제를 내는 일이 끔찍할 정도로 자주 있지는 않습니다(그런 기술이 특별히 중요한 팀에서 일하게 되는 것이 아니라면). 하지만 쓰레드, 특히 교착상태에 대한 일반적 이해도를 평가하기 위한 문제는 어떤 회사에서라도 상대적으로 자주 출제되는 편입니다.


Java의 쓰레드

Java의 모든 쓰레드는 java.lang.Thread 클래스로 만들고 제어합니다. 독립적인 응용프로그램의 경우, main 함수를 호출하면 자동적으로 하나의 사용자 쓰레드가 만들어지는데, 이 쓰레드를 주 쓰레드(main thread)라고 부릅니다.


Java에서 쓰레드를 구현하는 방법은 다음의 두 가지입니다.


- java.lang.Runnable 인터페이스를 구현

- java.lang.Thread 클래스를 계승


이 두 가지 방법에 대해서 지금부터 살펴보겠습니다.


Runnable 인터페이스를 구현하는 방법

Runnable 인터페이스는 아래의 단순한 구조를 갖습니다.


    public interface Runnable {
        void run();
    }


이 인터페이스를 사용해 쓰레드를 생성하고 사용하려면 다음과 같이 합니다.


1. Runnable 인터페이스를 구현하는 클래스를 만듭니다. 이 클래스로 만든 객체는 Runnable 객체가 됩니다.

2. Thread 타입의 객체를 만들때, Runnable 객체를 생성자에 인자로 넘깁니다. 이 Thread 객체는 이제 run() 메서드를 구현하는 Runnable 객체를 소유하게됩니다.

3. 이렇게 만든 Thread 객체의 start() 메서드를 호출합니다.


    public static class RunnableThreadExample implements java.lang.Runnable{
    
        public int count = 0;

        @Override
        public void run() {
            System.out.println("RunnableThread starting.");
            try {
                while(count < 5) {
                    Thread.sleep(500);
                    count++;
                }
            } catch(InterruptedException exc) {
                System.out.println("RunnableThread interrupted.");
            }
            System.out.println("RunnableThread terminating.");
        }
    }
    
    public static void main(String[] args) {
        RunnableThreadExample instance = new RunnableThreadExample();
        Thread thread = new Thread(instance);
        thread.start();
        
        /* 쓰레드 개수가 5가 될때까지 (천천히) 기다린다. */
        while(instance.count != 5) {
            try {
                thread.sleep(250);
            } catch(InterruptedException exc) {
                exc.printStackTrace();
            }
        }
    }

실제로 해야하는 일은 run() 메서드를 구현하는 것뿐입니다. 그러면 해당 클래스의 객체를 만든 다음에 이를 new Thread(obj)의 인자로 넘기고 start()를 호출하기만 하면 됩니다.



Thread 클래스 계승

대신, Thread 클래스를 계승하여 쓰레드를 만들 수도 있습니다. 그러려면 거의 항상 run() 메서드를 오버라이드 해야 하며, 하위 클래스의 생성자는 상위 클래스의 생성자를 명시적으로 호출해야 합니다.


class MyThreadExample {
    
    public static class ThreadExample extends java.lang.Thread {
        int count = 0;
        
        public void run() {
            System.out.println("Thread starting.");
            try {
                while(count < 5) {
                    Thread.sleep(500);
                    System.out.println("In Thread, count is " + count);
                    count++;
                }
            } catch (InterruptedException exc) {
                System.out.println("Thread interrupted.");
            }
            System.out.println("Thread terminating.");
        }
    }
    
    public static void main(String args[]) {
        ThreadExample instance = new ThreadExample();
        instance.start();
        
        while (instance.count != 5) {
            try {
                Thread.sleep(250);
            } catch (InterruptedException exc) {
                exc.printStackTrace();
            }
        }
    }
    
}


이 코드는 앞서 살펴본 코드와 아주 비슷합니다. 인터페이스를 구현하는 대신 Thread 클래스를 계승하고 있으므로, 생성한 객체에 직접 start()를 호출한다는 차이가 있을 뿐입니다.


Thread 계승 vs Runnable 인터페이스 구현

쓰레드를 생성할 때 Runnable 인터페이스를 구현하는 쪽이 나은 경우가 두가지있습니다.


- Java는 다중 상속을 지원하지 않습니다. 따라서 Thread 클래스를 상속하게 되면 하위 클래스는 다른 클래스는 상속할 수가 없습니다. Runnable 인터페이스를 구현하는 클래스는 다른 클래스를 상속받을 수 있습니다.

- Thread 클래스의 모든 것을 상속받는 것이 너무 부담되는 경우에는 Runnable을 구현하는 편이 나을지도 모릅니다.



동기화와 락


어떤 프로세스 안에서 생성된 쓰레드들은 같은 메모리 공간(memory space)을 공유합니다. 그래서 좋을 때도 있고, 나쁠 때도 있습니다. 쓰레드가 서로 데이터를 공유할 수 있다는 것은 장점입니다. 하지만 두 쓰레드가 같은 자원을 동시에 변경할 수도 있다는 문제가 있습니다. Java는 공유 자원에 대한 접근을 제어하기 위한 동기화 방법을 제공합니다.


Synchronized와 lock이라는 키워드는 서로 다른 쓰레드가 적절히 동기화되도록 하는 수단을 제공합니다.


동기화된 쓰레드

통상적으로 공유 자원에 대한 접근을 제어하고자 할 때에는 synchronized 키워드를 사용합니다. 이 키워드는 메서드에 적용할 수도 있고, 특정한 코드 블록에 적용할 수도 있습니다. 어떤 객체에 여러 쓰레드가 달려드는 경우, 이 키워드가 적용된 코드는 동시에 실행될 수 없습니다.


아래의 예제를 통해 살펴보겠습니다.


class MyClass extends Thread {
    private String name;
    private MyObject myObj;
    
    public MyClass(MyObject obj, String n) {
        name = n;
        myObj = obj;
    }
    
    public void run() {
        myObj.foo(name);
    }
}

class MyObject {
    public synchronized void foo(String name) {
        try {
            System.out.println("Thread : " + name + ".foo(): starting");
            Thread.sleep(3000);
            System.out.println("Thread " + name + ".foo(): ending");
        } catch (InterruptedException exc) {
            System.out.println("Thread : " + name + ": interrupted.");
        }
    }
}


MyClass로 만든 두 쓰레드가 foo를 동시에 호출할 수 있을까요? 상황에 따라 다릅니다. 그 두 쓰레드가 들고 있는 객체가 같은 MyObject 객체라면 동시 호출은 불가능 합니다. 하지만 다른 객체라면 호출할 수 있습니다.


/* 서로 다른 객체인 경우 동시에
 * MyObject.foo() 호출이 가능하다 */

MyObject obj1 = new MyObject();
MyObject obj2 = new MyObject();
MyClass thread1 = new MyClass(obj1, "1");
MyClass thread2 = new MyClass(obj2, "2");
thread1.start();
thread2.start();


/* 같은 obj를 사용한다. 두 쓰레드 가운데 하나만
 * foo를 실행할 수 있고, 다른 쓰레드는 기다려야 한다. */

MyObject obj = new MyObject();
MyClass thread1 = new MyClass(obj, "1");
MyClass thread2 = new MyClass(obj, "2");
thread1.start();
thread2.start();


정적(static) 메서드에 synchronized를 적용하면 클래스 락(class lock)에 의해 동기화됩니다. 같은 클래스에 있는 동기화된 정적 메서드를 두 쓰레드가 동시 실행하는 것은 불가능합니다. 설사 서로 다른 객체를 통해 호출하고 있다고 해도 말입니다.



동기화된 블록

이와 비슷하게, 특정한 코드 블록에도 synchronized 키워드를 적용할 수 있습니다. 메서드를 동기화하는 것과 아주 비슷하게 동작합니다.


class MyClass extends Thread {
    private String name;
    private MyObject myObj;
    
    public MyClass(MyObject obj, String n) {
        name = n;
        myObj = obj;
    }
    
    public void run() {
        myObj.foo(name);
    }
}

class MyObject {
    public void foo(String name) {
        synchronized(this) {
            // ...
        }
    }
}

메서드를 동기화하는 것과 마찬가지로, MyObject 객체 하나당 하나의 쓰레드 만이 synchronized 블록 안의 코드를 실행할 수 있습니다. 다시 말해 thread1과 thread2가 동일한 MyObject 객체를 갖고 있다면, 그 가운데 하나만 그 코드를 실행할 수 있습니다.



락(Lock)

좀 더 세밀하게 동기화를 제어하고 싶다면, 락을 이용합니다. 락(모니터라고 하기도 한다)을 공유 자원에 붙이면 해당 자원에 대한 접근을 동기화할 수 있습니다. 쓰레드가 해당 자원을 접근하려면 우선 그 자원에 붙어있는 락을 획득해야 합니다. 특정 시점에 락을 쥐고 있을 수 있는 쓰레드는 하나뿐입니다. 따라서 해당 공유자원은 한번에 한 쓰레드만이 사용할 수 있습니다.


어떤 자원을 프로그램 내의 이곳저곳에서 사용하는데, 한번에 한 쓰레드만 사용하도록 만들고자 할 때 락을 많이 사용합니다. 아래의 코드에 그 예를 보였습니다.


public class LockedATM {
    private Lock lock;
    private int balance = 100;
    
    public LockedATM() {
        lock = new ReentrantLock();
    }
    
    public int withdraw(int value) {
        lock.lock();
        int temp = balance;
        try {
            Thread.sleep(100);
            temp = temp - value;
            Thread.sleep(100);
            balance = temp;
        } catch(InterruptedException e) {}
        
        lock.unlock();
        return temp;
    }
    
    public int deposit(int value) {
        lock.lock();
        int temp = balance;
        try {
            Thread.sleep(100);
            temp = temp + value;
            Thread.sleep(300);
            balance = temp;
        } catch (InterruptedException e) { }
        
        lock.unlock();
        return temp;
    }
}


잠재적으로 어떤 문제들이 발생할 수 있는지 보이기 위해, 의도적으로 withdraw와 deposit이 실행되는 속도를 늦추기 위한 코드를 넣었습니다. 위의 코드가 보여주려고하는 상황은 매우 실제적입니다. 락을 사용하면 공유된 자원이 예기치 않게 변경되는 일을 막을 수 있습니다.



교착상태와 교착상태 방지

교착 상태로 어떤 쓰레드가 다른 쓰레드가 들고 있는 락이 풀리기를 기다리고 있는데, 두 번째 쓰레드는 자신을 기다리는 바로 그 쓰레드가 들고 있는 락이 풀리기를 기다리는 상황을 일컫습니다. (여러 쓰레드가 관계되어 있더라도, 같은 상황이 발생할 수 있습니다) 모든 쓰레드가 다른 쓰레드가 락을 풀기만 기다리기 때문에, 결국 문한히 대기 상태에 빠지게 되었습니다. 이런 쓰레드를 교착상태에 빠졌다고 합니다.


교착 상태가 발생하려면, 다음의 네 가지 조건이 전부 충족되어야 합니다.


1. 상호 배제(mutual exclusion): 한 번에 한 프로세스만 공유 자원을 사용할 수 있다(좀 더 정확하게 이야기 하자면, 공유 자원에 대한 접근 권한이 제한된다. 자원의 양이 제한되어 있더라도 교착상태는 발생할 수 있다)

2. 들고 기다린다(hold and wait): 공유 자원에 대한 접근 권한을 갖고 있는 프로세스가, 그 접근 권한을 양보하지 않은 상태에서 다른 자원에 대한 접근권한을 요구할 수 있다.

3. 선취(preemption) 불가능: 한 프로세스가 다른 프로세스의 자원 접근 권한을 강제로 취소할 수 없다.

4. 대기 상태의 사이클(circular wait): 두 개 이상의 프로세스가 다른 프로세스가 자원 접근 권한을 개방하기를 기다리는데, 그 관계에 사이클이 존재한다.


교착 상태 방지는 이 조건들 가운데 하나를 제거하는 것입니다. 하지만 이들 조건 가운데 상당수는 만족되기 어려운 것이라서 까다롭습니다. 가령 많은 공유 자원은 한번에 한 프로세스만 사용할 수 있기 때문에 1번 조건을 제거하기가 어렵습니다. 대부분의 교착상태 방지 알고리즘은 4번 조건, 즉 대기 상태의 사이클이 발생하는 일을 막는데 초점을 맞춥니다.