decoration은 '장식(포장)'이란 뜻이다. 빵집에서 케이크를 만들 때 먼저 둥근 모양의 빵을 만든다. 이 위에 초콜릿을 바르면 초콜릿 케이크가 되고, 치즈를 바르면 치즈 케이크가 된다. 또 생크림을 바르고 과일을 많이 올려놓으면 과일 생크림 케이크가 된다.

 

이처럼 기존에 구현되어 있는 클래스(둥근 모양의 빵)에 그때그때 필요한 기능(초콜릿, 치즈, 생크림)을 추가(장식, 포장)해나가는 설계 패턴을 decorator 패턴이라고 한다. 이것은 기능 확장이 필요할 때 상속의 대안으로 사용한다.

그림에서 decorator 클래스가 기존에 구현되어 있는 클래스(둥근 모양의 빵)에 해당되고, concreteDecorator클래스는 그때그때 필요한 기능(초콜릿, 치즈, 생크림)을 추가(장식, 포장)해나가는 것에 해당된다.

 

카페 음료 가격을 계산하기 위해 우리는 클래스를 설계해야 한다고 하자. 보통 가장 많이 생각할 수 있는 방안이 상속을 이용한 구현이다. 아래와 같이 쉽게 구현할 수 있다.

 

기본적으로 커피를 제조할때 에스프레소를 내리고 해당 에스프레소에 다른 첨가물을 첨가하여 커피를 제조한다. 예를 들어 카페모카는 에스프레소에 우유,초코가 들어가므로 에스프레소 가격(2)+우유,초코(2)로 총 4라는 가격이 책정된다고 생각해보자. 그렇다면 상속을 이용하여 아래와 같이 구현할 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public abstract class Beverage {
    
    private String description;
    
    public String getDescription() {
        return description;
    }
    
    public void setDescription(String description) {
        this.description = description;
    }
 
    public abstract int cost();
}
 
public class Espresso extends Beverage{
    
    public Espresso() {
        super.setDescription("에스프레쏘");
    }
 
    @Override
    public int cost() {
        return 2;
    }
    
}
 
public class CafeMocha extends Beverage{
    
    public CafeMocha() {
        super.setDescription("카페모카");
    }
    
    @Override
    public int cost() {
        return 2+2;
    }
    
    
}
cs

 

위와 같이 새로운 음료가 추가되었으므로, 새로운 클래스가 추가된다. 만약 휘핑크림이 추가된 카페모카라면? 아래와 같이 추가적인 클래스가 생성되어야 할 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CafeMochaWithWhip extends Beverage{
 
    public CafeMochaWithWhip() {
        super.setDescription("카페모카 휘핑크림 추가");
    }
    
    @Override
    public int cost() {
        return 2+2+1;
    }
    
    
}
cs

 

얼핏보면 괜찮은 것 같지만, 만약 음료의 종류가 엄청나게 많고 선택옵션이 엄청나게 많다면? 클래스수는 기하급수적으로 늘어날 것이다. 이것은 역시 디자인 원칙중 OCP를 위반하고 있는 것이다.(개방-폐쇄 원칙)

 

이러한 상황에서 사용하는 디자인 패턴이 데코레이터 패턴이다!

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public abstract class Beverage {
    
    private String description;
    
    public String getDescription() {
        return description;
    }
    
    public void setDescription(String description) {
        this.description = description;
    }
 
    public abstract int cost();
}
cs

 

데코레이터 패턴은 구성이라는 방법을 사용한다. 즉, 추상 타입을 가지는 데코레이터들 혹은 기본구성 클래스들이어야만 데코레이터를 이용할 수 있다. 왜냐하면 뒤에서 설명하겠지만 데코레이터 역할을 클래스들이 모두 추상타입의 구현체들을 감싸게 되는 구조이기 때문이다. 즉, 위 추상클래스는 기본구성(에스프레쏘) 그리고 데코레이터(선택옵션,모카,휘핑이 들어간 모카 등)들의 타입을 마춰주기 위한 추상타입의 역할을 할 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
public class Espresso extends Beverage{
    
    public Espresso() {
        super.setDescription("에스프레쏘");
    }
 
    @Override
    public int cost() {
        return 2;
    }
    
}
cs

 

추상타입의 Beverage를 상속하는 기본구성을 구현한 예제이다. 데코레이터 패턴의 핵심은 기본 구성으로부터 출발하여 이것저것 데코레이터 옵션을 덧붙여나가는 것이다. 즉, 데코레이터 패턴의 시작이 되는 기본구성은 바로 추상타입의 Beverage를 상속받아 구현한다.

 

1
2
3
public abstract class BeverageDecorator extends Beverage {
    
}
cs

 

데코레이터(추가옵션)들이 상속하게 될 추상클래스이다. 데코레이터들의 모든 타입 그리고 기본구성과 데코레이터들의 타입이 동일해야하므로 Beverage를 상속한다. 만약 데코레이터들의 추가적인 행위를 구현하려면 이쪽에 추상메소드로 선언해도 무방할듯하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Mocha extends BeverageDecorator{
 
    private Beverage beverage;
    
    public Mocha(Beverage beverage) {
        this.beverage=beverage;
    }
    
    @Override
    public int cost() {
        return 2+this.beverage.cost();
    }
    
}
 
public class Whipping extends BeverageDecorator{
    
    private Beverage beverage;
    
    public Whipping(Beverage beverage) {
        this.beverage=beverage;
    }
    
    @Override
    public int cost() {
        return 1+this.beverage.cost();
    }
    
}
cs

 

데코레이터 패턴의 핵심인듯하다. 데코레이터 객체들의 구현체이다. 잘보면 기본구성 혹은 데코레이터 객체들을 래핑하기 위해 하나의 인스턴스 변수를 가지고 있다는 것이 핵심이며 기본구성과 동일한 행위(가격계산,cost())를 오버라이드하여 구현하였으며 래핑한 객체의 동일한 행위 메소드를 호출하고 있는 것을 볼 수 있다. 이렇게 기본구성과 데코레이터 객체들의 상위타입을 마춰줌으로써 계속해서 생성자로 객체를 래핑하여 옵션을 추가 할 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class DecoratorMain {
 
    public static void main(String[] args) {
        
        Beverage espresso = new Espresso();
        System.out.println("description : "+espresso.getDescription() +" cost : "+espresso.cost());
        
        Beverage mocha = new Mocha(new Espresso());
        mocha.setDescription("카페모카");
        System.out.println("description : "+mocha.getDescription() +" cost : "+mocha.cost());
        
        Beverage mochaWithWhip = new Whipping(new Mocha(new Espresso()));
        mochaWithWhip.setDescription("카페모카 휘핑추가");
        System.out.println("description : "+mochaWithWhip.getDescription() +" cost : "+mochaWithWhip.cost());
        
    }
 
}
 
=>result
description : 에스프레쏘 cost : 2
description : 카페모카 cost : 4
description : 카페모카 휘핑추가 cost : 5
 
cs

 

메인클래스에서 데코레이터 패턴이 적용된 클래스를 사용하고 있는 것을 보니 많이 낯이 익다. 무엇일까? 바로 Java I/O들을 구현할 때, 데코레이터 패턴이 적용되었다! 

 

InputStream is = new BufferedInputStream(new FileInputStream("filepath"));

 

지금까지 간단히 데코레이터 패턴을 다루어보았다.

posted by 여성게
:

command의 의미는 '명령어'이다. 문서편집기의 복사(copy), 붙여넣기(paste), 잘라내기(cut) 등도 모두 명령어이다. 그런데 이런 명령어를 각각 구현하는 것보다는 [그림 5-52]처럼 하나의 추상 클래스 execute() 메서드를 하나 만들고 각 명령이 들어오면 그에 맞는 서브 클래스(copy_command)가 선택되어 실행하는 것이 효율적이다. 이는 함수 오버로딩(overloading)과 같은 추상화 개념을 사용한 것이다.

그러나 command 패턴은 단순히 명령어를 추상 클래스(abstract class)와 구체 클래스(copy_command,cut_command, paste_command)로 분리하여 단순화한 것으로 끝나지 않고, 명령어에 따른 취소(undo) 기능까지도 포함한다(사용자 입장에서는 해당 명령어를 실행했다가 취소(undo)하기도 하기 때문이다). 이렇게 프로그램의 명령어를 구현할 때는 command 패턴을 활용할 수 있다.

 

이러한 커맨드 패턴은 아주 예전에 사용하던 servlet에서도 사용하던 패턴입니다. FrontController를 최앞단에 두고 FrontController에서 모든 요청을 다 받은 후 커맨드 패턴을 이용하여 분기처리했습니다. 즉, 이렇게 어떠한 이벤트에 대해 실행될 기능이 다양하면서도 변경이 필요한 경우에 이벤트를 발생시키는 클래스를 변경하지 않고 재사용하고자 할때 유용한 패턴입니다.

 

만능 리모콘을 예제로 살펴보겠습니다. 리모콘은 실내의 전등을 키고 끄는 버튼이 있고 TV를 켜고 끄는 버튼도 있습니다. 추후에는 다양한 전자기기를 다룰 수 있는 버튼이 추가될 가능성이 있습니다. 이럴 경우에는 어떻게 커맨드 패턴을 적용해 볼 수 있을까요? 아래와 같이 쉽게 구현은 가능합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class SimpleRemoteController {
    
    private TV tv;
    private Light light;
    private Mode mode;
    
    enum Mode{
        TV,LIGHT;
    }
    
    public SimpleRemoteController(TV tv, Light light) {
        this.tv=tv;
        this.light=light;
    }
    
    public void setMode(Mode mode) {
        this.mode=mode;
    }
    
    public void pressOnButton() {
        if(this.mode.equals(Mode.TV)) {
            tv.turnOn();
        }else if(this.mode.equals(Mode.LIGHT)) {
            light.turnOff();
        }
    }
    
    ...
    
}
cs

이 코드에는 분명 문제가 있습니다. SOLID 원칙 중 분명 OCP에 위반되기 때문이죠. 확장에는 열려있고 변경에는 닫혀있어야 하는 원칙을 누가 봐도 어기고 있습니다. 기능이 추가될때마다 코드 변경이 필요하기 때문이죠. 하지만 이것을 커맨드 패턴을 이용해 구현한다면 리모콘을 컨트롤하는 클래스는 어떠한 행위에 대한 구체적인 구현을 몰라도 되며 구체적인 행동을 캡슐화하고 있는 커맨드 객체만 받으면 되기에 훨씬 확장이 수월한 코드가 나오게 됩니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class RemoteControlInvoker {
    
    private Command command;
    
    public RemoteControlInvoker(Command command) {
        this.command=command;
    }
 
    public void setCommand(Command command) {
        this.command = command;
    }
    
    public void pressedButton() {
        command.execute();
    }
    
}
cs

 

이제는 리모콘 역할을 하는 클래스에는 구체적인 행위가 정의되어 있지 않습니다. 생성자의 매개변수 혹은 setter를 이용하여 주입받은 커맨드 객체로 행위를 호출하는 호출자(Invoker) 역할을 하게됩니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public interface Command {
    public void execute();
}
 
public class TvOnCommand implements Command {
    
    private TV tv;
    
    public TvOnCommand(TV tv) {
        this.tv=tv;
    }
    
    @Override
    public void execute() {
        tv.turnOn();
    }
 
}
 
public class TvOffCommand implements Command {
    
    private TV tv;
    
    public TvOffCommand(TV tv) {
        this.tv=tv;
    }
    
    @Override
    public void execute() {
        tv.turnOff();
    }
 
}
 
public class LightOnCommand implements Command {
    
    private Light light;
    
    public LightOnCommand(Light light) {
        this.light=light;
    }
    
    @Override
    public void execute() {
        light.turnOn();
    }
 
}
 
public class LightOffCommand implements Command {
    
    private Light light;
    
    public LightOffCommand(Light light) {
        this.light=light;
    }
    
    @Override
    public void execute() {
        light.turnOff();
    }
 
}
 
cs

 

Command라는 인터페이스를 만들고 execute()라는 메소드를 선언합니다. 그리고 구체적인 요청(행위)을 하나의 커맨드 객체로 캡슐화 합니다. 해당 커맨드 객체는 요청을 커맨드 객체에게 간접적으로 받아서 처리하는(Receiver)를 한번 감싸는 행위 캡슐화 역할을 하게 됩니다.

특이하게 이제는 클라이언트 입장에서 어떠한 행위든지 상관없이 execute()만 호출하면 되는 것입니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class TV {
    
    public void turnOn() {
        System.out.println("TV 켰음");
    }
    public void turnOff() {
        System.out.println("TV 껐음");
    }
}
 
public class Light {
    
    public void turnOn() {
        System.out.println("불 켰음");
    }
    public void turnOff() {
        System.out.println("불 껐음");
    }
}
 
 
cs

 

리시버 객체의 구현입니다.

 

이제 리모콘 역할을 하는 Invoker는 어떠한 기능이 추가되더라도 코드 변경없이 기능을 얼마던지 추가 할 수 있습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CommandMain {
 
    public static void main(String[] args) {
        
        TV tv = new TV();
        TvOnCommand tvOnCommand = new TvOnCommand(tv);
        TvOffCommand tvOffCommand = new TvOffCommand(tv);
        
        Light light = new Light();
        LightOnCommand lightOnCommand = new LightOnCommand(light);
        LightOffCommand lightOffCommand = new LightOffCommand(light);
        
        RemoteControlInvoker invoker = new RemoteControlInvoker(tvOnCommand);
        invoker.pressedButton();
        invoker.setCommand(tvOffCommand);
        invoker.pressedButton();
        invoker.setCommand(lightOnCommand);
        invoker.pressedButton();
        invoker.setCommand(lightOffCommand);
        invoker.pressedButton();
        
    }
 
}
cs

 

마지막으로 일련의 행동을 연속적으로 처리할 수 있는 커맨드 패턴은 어떻게 구현할까 생각해볼 수 있습니다. 이런 패턴을 매크로 커맨드 패턴이라고 합니다. 구현은 간단히 아래와 같이 진행할 수 있습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class MacroCommand implements Command {
    
    private List<Command> commands;
    
    public MacroCommand() {
        commands= new ArrayList<>();
    }
    
    public void addCommand(Command...commands) {
        if(commands.length < 1throw new NullPointerException();
        
        for(Command c : commands) {
            this.commands.add(c);
        }
    }
    
    @Override
    public void execute() {
        if(commands.size()>0) {
            commands.stream().forEach(c->c.execute());
        }
    }
 
}
 
public class CommandMain {
 
    public static void main(String[] args) {
        
        TV tv = new TV();
        TvOnCommand tvOnCommand = new TvOnCommand(tv);
        TvOffCommand tvOffCommand = new TvOffCommand(tv);
        
        Light light = new Light();
        LightOnCommand lightOnCommand = new LightOnCommand(light);
        LightOffCommand lightOffCommand = new LightOffCommand(light);
        
        MacroCommand macroCommand = new MacroCommand();
        macroCommand.addCommand(tvOnCommand,
                                tvOffCommand,
                                lightOnCommand,
                                lightOffCommand);
        RemoteControlInvoker invoker = new RemoteControlInvoker(macroCommand);
        invoker.pressedButton();
        
    }
 
}
cs

 

여기까지 간단한 커맨드 패턴 예제를 다루어봤습니다. 사실 커맨드 패턴은 위보다도 더욱 복잡하게 사용가능한 패턴입니다. 예를 들어 상태값을 가져 상태에 따른 행위를 할 수 있고, 또한 확장하여 일련의 작업들을 작업 큐에 넣어 쓰레드들이 각각 하나의 Command를 맡아서 작업을 할 수도 있는 등 구현의 범위는 넓습니다.

posted by 여성게
:

state의 의미는 '상태'이다. 엘리베이터의 정지, 하강, 상승 상태처럼 객체 상태도 상황에 따라 달라진다. state 패턴은 동일한 동작을 객체 상태에 따라 다르게 처리해야 할 때 사용한다.

이렇게 하나의 객체에 여러 가지 상태(예, 정지, 상승, 하강)가 존재할 때 패턴을 사용하지 않고 프로그래밍을 하면 if 문 또는 switch 문을 사용하여 처리한다. 그런데 신규 상태(예, 문 열림, 문 닫힘)가 발생하면 프로그램을 다시 수정해야 한다.

이런 경우에 state 패턴은 그림처럼 객체 상태를 캡슐화하여 클래스화(state 인터페이스)함으로써 그것을 참조하게 하는 방식으로 상태에 따라 다르게 처리(upState, stopState, downState)할 수 있도록 한 것이다. 따라서 변경 시(신규 상태 추가) 원시 코드의 수정을 최소화할 수 있고, 유지보수를 쉽게 할 수 있다.

 

형광등을 예로 들어보자. 형광등의 첫 상태는 스위치가 꺼진 OFF 상태라고 생각하자. 상태가 OFF인 상태에서 스위치를 ON하면 불이 켜질 것이다. 그렇지만 상태가 ON인 상태에서 스위치를 ON하면 이미 불이 켜진 상태라 아무런 동작을 하지 않을 것이다. 그 반대도 역시 동일하다. 그렇다면 이렇게 동작하는 형광등 클래스는 어떻게 설계하면 좋을까? 가장 간단한 구현은 아래와 같을 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class NotStatePattern {
    
    public static final int ON=1;
    public static final int OFF=0;
    
    //현 상태
    private int currentState;
    
    public NotStatePattern() {
        currentState=OFF;
    }
    
    //ON 스위치 push
    public void on_button() {
        if(currentState==ON) System.out.println("동작 없음.");
        else {
            System.out.println("불 켜짐!");
            currentState=ON;
        }
    }
    //OFF 스위치 push    
    public void off_button() {
        if(currentState==OFF) System.out.println("동작 없음.");
        else {
            System.out.println("불 꺼짐!");
            currentState=OFF;
        }
    }
    
}
cs

 

ON,OFF 상태값을 가지는 상수와 현재 형광등의 상태를 가지고 있는 변수. 그리고 스위치를 ON,OFF할때 발생되는 행위를 메소드로 구현해놓았다. 얼핏 봤을 때, 나쁘지 않은 구현일 수 있다. 하지만 예를 들어 수면모드가 들어간 형광등이라면? 스위치가 ON인 상태에서 다시 한번 ON 스위치를 누를 경우 수면 모드로 들어간다. 그리고 수면 모드에서 OFF 스위치를 누르면 형광등이 꺼진다. 만약 복잡한 시스템이라면 이러한 상태값은 더 많이 갖게 될 것이고, 그럼 많은 상태에 따른 행위를 위와 같이 if문 혹은 switch 문으로 다뤄야할 것이며 이는 상태가 추가될때 마다 코드가 수정되야하고 가독성도 떨어지며 최종적으로는 유지보수가 힘든 코드가 될 것이다.

 

이러한 상황에서 사용할 수 있는 디자인 패턴이 스테이트 패턴(State Pattern)이다. 아마 예제 코드를 본다면 우리 이미 다루어본 스트레티지 패턴(전략패턴)과 비슷하다 볼 수 있을 것이다.

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Light {
    
    private State state;
    
    public Light() {
        state=OffState.getInstance();
    }
    
    public void setState(State state) {
        this.state=state;
    }
    
    public void onButton() {
        this.state.onButton(this);
    }
    
    public void offButton() {
        this.state.offButton(this);
    }
    
}
cs

 

형광등 클래스이다. 마치 스트레티지 패턴의 Context 객체와 비슷하다. 즉, 해당 객체는 실제 알고리즘을 수행하는 실체 객체를 알지못한다. 단순히 남에게 행위를 위임해주는 역할이다.

 

1
2
3
4
5
6
public interface State {
    
    public void onButton(Light light);
    public void offButton(Light light);
    
}
cs

 

디자인 원칙을 떠올려보자. 변하는 것은 잘 변하지 않는 것과 분리해라. 즉, 변하는 녀석들을 캡슐화해라! 

 

우리는 변하는 것은 상태(State)라는 것을 알고있다. 이러한 상태를 인터페이스로 분리시켰고 각 상태에 따른 구현 클래스를 만들어 줄 것이다. 여기서 스트래티지 패턴과 조금 다른 점은 Light(Context)객체가 행위를 위임할때 자신의 상태를 변경하기 위하여 자기자신을 메소드 매개변수로 넘기는 것을 주목하자! 차이점은 마지막에 다룰 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
public class OnState implements State {
    
    private OnState() {}
    
    private static class LazyHolder{
        public static final OnState ON=new OnState();
    }
    
    public static OnState getInstance() {
        return LazyHolder.ON;
    }
 
    @Override
    public void onButton(Light light) {
        light.setState(SleepState.getInstance());
        System.out.println("잠자기 모드!");
    }
 
    @Override
    public void offButton(Light light) {
        light.setState(OffState.getInstance());
        System.out.println("불 꺼짐!");
    }
 
}
 
public class OffState implements State {
    
    private OffState() {}
    
    private static class LazyHolder{
        public static final OffState OFF=new OffState();
    }
    
    public static OffState getInstance() {
        return LazyHolder.OFF;
    }
    
    @Override
    public void onButton(Light light) {
        light.setState(OnState.getInstance());
        System.out.println("불 켜짐!");
    }
 
    @Override
    public void offButton(Light light) {
        System.out.println("동작 없음!");
    }
 
}
 
public class SleepState implements State {
    
    private SleepState() {}
    
    private static class LazyHolder{
        public static final SleepState SLEEP=new SleepState();
    }
    
    public static SleepState getInstance() {
        return LazyHolder.SLEEP;
    }
    
    @Override
    public void onButton(Light light) {
        light.setState(OnState.getInstance());
        System.out.println("잠자기 모드 해제!(ON 상태)");
    }
 
    @Override
    public void offButton(Light light) {
        light.setState(OffState.getInstance());
        System.out.println("불 꺼짐!");
    }
 
}
cs

 

각 상태들의 구현체이다. 여기서 조금 특이한 것은 각 상태의 구현체들이 싱글톤으로 구현되어 있다는 것이다. 이것은 상태들이 행위를 수행하면서 Light 객체의 상태를 수시로 바꾸어주기 때문에 싱글톤으로 작성하지 않으면 매번 새로운 인스턴스가 생겨 불필요한 메모리를 잡아 먹을 것이고 전체적으로 성능 저하의 원인이 될 것이기 때문에 싱글톤으로 작성하였다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class StatePatterMain {
 
    public static void main(String[] args) {
        Light l = new Light();
        
        l.offButton();
        l.onButton();
        l.onButton();
        l.onButton();
        l.offButton();
        l.onButton();
        l.offButton();
    }
 
}
 
=>result
동작 없음!
불 켜짐!
잠자기 모드!
잠자기 모드 해제!(ON 상태)
불 꺼짐!
불 켜짐!
불 꺼짐!
cs

 

마지막 결과이다. 잘 보면 스트래티지 패턴과 차이점이 보일 것이다. 스트래티지 패턴을 떠올려보면 클라이언트 쪽에서 알고리즘을 변경하기 위하여 setter를 호출해 직접 수행할 알고리즘을 주입해주었던 것을 기억할 것이다. 즉, 클라이언트가 구체적인 알고리즘의 수행까지는 몰라도 어느정도 무엇무엇이 있는지 정도는 알고 있어야 한다는 것이다. 하지만 스테이트 패턴은 각 상태 구현 클래스들이 자신들의 행위를 수행하면서 직접 Context(Light)객체의 상태를 변경해주기 때문에 클라이언트 입장에서는 직접 상태를 조작하거나 하지 않아도 된다는 점이다. 즉, 클라이언트는 상태를 몰라도 된다라는 뜻이다.(전략을 직접 클라이언트가 바꿔서 사용해야하는 스트래티지 패턴과는 조금은 상반된다.)

 

즉, 용도가 조금 다른 패턴이라고 볼 수 있다.

 

스트래지티 패턴 - 사용자가 쉽게 알고리즘 전략을 바꿀 수 있도록 유연성을 제공. 상속의 한계를 해결하기 위하여 나온 패턴

스테이트 패턴 - 한 객체가 동일한 동작을 상태에 따라 다르게 수행해야 할 경우 사용하는 패턴

posted by 여성게
:

 

어떤 클래스에 변화가 일어났을 때, 이를 감지하여 다른 클래스에 통보해주는 것이 observer 패턴이다. 그림을 보면 이 패턴은 외부 객체의 상태 변화에 따라(subject 클래스) observer 객체(observer 인터페이스 클래스)는 이에 상속되어 있는 다른 객체(concreteObserver)들에게 변화된 상태를 전달하고(notify 메서드) 상속된 객체들은 그에 맞게 기능을 수행하는 형태로 구성된다.

이 패턴에 해당되는 예는 관심 있는 어떤 블로그를 미리 등록해놓으면 그 블로그에 새 글이 올라올 때마다 자동으로 알려주는 것과 같다. 즉 observer 패턴은 어떤 일이 생기면 미리 등록한 객체들에게 상태 변화를 알려주는 역할을 한다.

 

다시 말하자면 Subject 객체의 상태에 변화가 있을 때, Observer들에게 상태변화를 알린다. 그 이후 Observer들은 상태변화에 맞게 행동을 취한다. 우리가 잘 알고 있는 신문사 구독과 비슷한 이론인 것이다. 어떤 고객이 신문사에 구독을 신청하면 신문사가 새로운 신문을 발행할때마다 고객에게 신문을 보내는 것과 같은 행동이다.

 

예를 들어 온도,습도,기압 등의 기상 상태를 센서로부터 수집하는 Subject가 있다고 생각하고, 해당 Subject는 기상 상태의 변화가 있을때, Observer들에게 변화를 알리고 Observer들은 기상 상태 변화에 따른 새로운 데이터를 디스플레이에 출력하는 것을 구현하는 상황이다.

 

여기서 적용할 수 있는 패턴이 옵저버 패턴이다. 바로 구현해보자.(예제는 단순히 온도 변화만 다루었다.) 그리고 구독을 해지하는 등의 코드도 구현하지 않았고 상태변화가 있을 때, 옵저버들에게 알려주는 기능만 구현하였다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Subject extends Observable{
    
    private int temp;
    
    public void changeTemp(int temp) {
        setChanged();
        notifyObservers(temp);
    }
    
    public void changeTemp() {
        setChanged();
        notifyObservers();
    }
    
    public void setChangeTemp(int temp) {
        this.temp = temp;
        changeTemp(temp);
    }
 
    public int getTemp() {
        return temp;
    }
 
    public void setTemp(int temp) {
        this.temp = temp;
        changeTemp();
    }
    
}
 
cs

 

Subject 클래스이다. 이미 자바에는 옵저버 패턴의 구현을 쉽게 해주는 API를 제공하고 있다. Subject를 구현하기 위해서는 Observable 클래스를 상속받아 확장하여 사용하면 된다.(사실 Observable을 확장하면 단점들이 존재한다. 반드시 상속을 강요하고 있으며 추상클래스가 아닌 일반 클래스로 되어 있기에 나중에 필요한 기능을 확장하기 위하여 상속을 받을 수 없다. 왠만하면 새로 구현해서 사용하는 것도 추천한다.)

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class Observer1 implements Observer {
    
    private Observable sub;
    private int temp;
    
    public Observer1(Observable sub) {
        this.sub=sub;
        sub.addObserver(this);
    }
 
    @Override
    public void update(Observable o, Object arg) {
        if(o instanceof Subject) {
            if(arg != nullthis.temp=(int)arg;
            else this.temp=((Subject) o).getTemp();
        }
        
        display();
 
    }
    
    public void display() {
        System.out.println("Observer1 - "+temp);
    }
 
}
 
public class Observer2 implements Observer {
    
    private Observable sub;
    private int temp;
    
    public Observer2(Observable sub) {
        this.sub=sub;
        sub.addObserver(this);
    }
 
    @Override
    public void update(Observable o, Object arg) {
        if(o instanceof Subject) {
            if(arg != nullthis.temp=(int)arg;
            else this.temp=((Subject) o).getTemp();
        }
        display();
    }
 
    public void display() {
        System.out.println("Observer2 - "+temp);
    }
    
}
cs

 

Observer라는 인터페이스를 상속한 Observer 클래스 2개를 구현하였다. 해당 인터페이스에는 Subject에서 호출할 update 메소드만 오버라이드해서 구현해주면 된다. 옵저버 구현체들에서는 push 방식으로 데이터를 매개변수(arg)로 받아도 되지만 Subject 클래스에서 상태값에 대한 getter를 열어주어서 Observer 구현체들이 pull(getter호출)해서 가져가도 된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ObserverMain {
 
    public static void main(String[] args) {
        
        Subject sub= new Subject();
        
        Observer observer1 = new Observer1(sub);
        Observer observer2 = new Observer2(sub);
        
        sub.changeTemp(20);
        sub.setTemp(10);
    }
 
}
 
=>결과
Observer2 - 20
Observer1 - 20
Observer2 - 10
Observer1 - 10
cs

 

결과적으로 Observer 구현체들의 어떠한 메소드도 호출하지 않았음에도 불구하고 Subject 클래스의 상태가 바뀔 때마다 Observer의 메소드가 호출되어 동작한다. 이러한 패턴이 옵저버 패턴이다. 이러한 패턴을 다른 말로 pub/sub 패턴이라고 부르기도 한다. 이러한 옵저버 패턴을 자바는 내부적으로 비동기처리 API의 내부 구현로직으로 사용하기도 한다.

 

위의 구현을 보면 Observer 구현체들의 인스턴스 변수로 Observable 인터페이스가 선언되어 있는 이유는 우선 생성자가 호출될때 해당 Observable 변수가 초기화되고, 이후에 별로 쓰이지 않는 듯 보이지만, 필자가 이야기 했듯 해당 예제에는 구독을 해지하는 등의 로직이 포함되어 있지않다. 추후에 구독을 해지하는 로직을 Observer 구현체들에게 메소드 형태로 넣어주고 내부적으로 Observable의 deleteObserver를 호출하게 하면 된다.

posted by 여성게
:

 

스트레티지 패턴(Strategy Pattern,전략패턴)은 알고리즘군을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만들어준다. 스트레티지 패턴을 활용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.

 

바로 예제를 살펴보자.

 

Duck이라는 추상클래스가 있다고 생각해보자. 해당 추상클래스는 다양한 오리라는 서브클래스를 만들기 위해 여러가지 공통 메소드와 확장을 위한 추상 메소드를 가진 추상클래스이다. 만약 아래와 같은 요청이 있다고 생각해보자.

 

"모든 오리에게 날 수 있는 기능과 오리소리를 내는 기능을 추가해라. 하지만 구현체마다 해당 행동들은 모두 달라질 수 있다"

 

어떤 식으로 구현을 하면 좋을까? 두개의 행동이 모든 구현체마다 달라질 수 있다는데, 추상클래스에서 추상메소드로 만들어줘서 모든 서브 클래스에서 오버라이드 할 수 있게 할까? 그렇다면 어떠한 문제가 생길수 있을까 생각해보자. 문제는 이것이다. 서브 클래스에서 나는 행동과 오리소리를 내는 행동을 오버라이드 한다면 과연 이러한 코드는 재사용이 가능할까? 문제를 다시 한번보자. 

 

"구현체마다 행동들은 모두 달라질 수 있다"

 

달라질 수 있다지, 모든 구현체마다 다르다는 아니다. 이 말은 종류가 다른 오리지만 행동이 같은 교집합이 존재할 수 있다는 것이다. 그렇다면 서브클래스에서 슈퍼클래스의 나는 행동과 오리소리를 내는 행동을 오버라이드한다면 같은 행동을 갖는 다른 서브 클래스들이 동일한 코드가 중복되서 나타날 것이다. 그러면 다음 해결책을 보자.

 

"달라지는 행동들을 별도의 인터페이스로 분리하여 해당 기능이 필요한 오리 서브 클래스에 구현을 강제하자."

 

이 해결책 또한 재활용하기 힘들다. 서브 클래스에서 인터페이스의 구현을 강제하여 나는 행동과 오리소리 행동을 구현한다면 이 역시 재활용할 수 없는 코드가 될 것이다. 즉, 구현 클래스에서만 구현코드가 나타나기 때문에 행동이 같은 다른 서브 클래스에서 해당 코드를 재활용할 수 없다.

 

여기서 사용할 수 있는 디자인 패턴이 스트래티지 패턴(Strategy Pattern)이다. 이 패턴의 핵심은 달라지는 행동부분을 분리하고 캡슐화하여 클라이언트와는 독립적으로 행동을 주입할 수 있게 하는 것이다. 바로 구현 코드를 살펴보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public abstract class Duck {
    
    private FlyBehavior fly;
    private QuackBehavior quack;
    
    public void swim() {
        System.out.println("헤엄친다.");
    }
    
    public void fly() {
        fly.fly();
    }
    
    public void quack() {
        quack.quack();
    }
 
    public void setFly(FlyBehavior fly) {
        this.fly = fly;
    }
 
    public void setQuack(QuackBehavior quack) {
        this.quack = quack;
    }
    
}
cs

 

위의 추상 클래스를 살펴보면 이 추상클래스는 다양한 오리의 구현체의 공통 행동들을 담은 추상클래스이다. 그리고 오리 구현체들마다 달라질 수 있는 행동을 인터페이스로 분리하여 캡슐화하고 있다. 이 말은 상속이나 구현을 강제하는 것이 아니고 인스턴스 변수로 달라지는 행동을 구성(composition)하였다. 그리고 달라지는 행동들은 인터페이스를 구현하여 특정 클래스의 군(알고리즘군)으로 만들어지기 때문에 같은 행동을 하는 다른 오리들이 같은 클래스를 사용하므로 재사용성이 높아진다.

 

1
2
3
4
5
6
7
public interface FlyBehavior {
    public void fly();
}
 
public interface QuackBehavior {
    public void quack();
}
cs

 

달라지는 행동들은 인터페이스로 분리하였다.

 

1
2
3
4
5
6
7
public class ADuck extends Duck{
 
}
 
public class BDuck extends Duck{
 
}
cs

 

추상클래스를 상속받은 오리서브 클래스들은 만들었다. 특징은 ADuck은 헤엄은 칠 수 있지만 날지 못하고 오리소리를 내지 못한다. BDuck은 수영도 가능하고 날수도 있고 울수도 있다. 과연 이러한 달라진 행동들을 실제로 어떻게 사용할까?

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class NotFlyBehavior implements FlyBehavior {
 
    @Override
    public void fly() {
        System.out.println("못 날아요");
    }
 
}
 
public class DoFlyBehavior implements FlyBehavior {
 
    @Override
    public void fly() {
        System.out.println("난다.");
    }
 
}
 
public class NotQuackBehavior implements QuackBehavior {
 
    @Override
    public void quack() {
        System.out.println("못 운다.");
    }
 
}
 
public class DoQuackBehavior implements QuackBehavior {
 
    @Override
    public void quack() {
        System.out.println("운다.");
    }
 
}
 
 
cs

 

구체적인 행동들을 구현한 클래스 집합군들이다.(나는 행동 집합, 오리소리는 내는 행동 집합)

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/*
 * ADuck은 날지못하고 울지도 못한다.
 * BDuck은 날수 있고 울 수도 있다.
 */
public class DuckMain {
 
    public static void main(String[] args) {
        
        Duck a = new ADuck();
        a.setFly(new NotFlyBehavior());
        a.setQuack(new NotQuackBehavior());
        
        
        Duck b = new BDuck();
        b.setFly(new DoFlyBehavior());
        b.setQuack(new DoQuackBehavior());
        
        System.out.println("===========ADuck===========");
        
        a.swim();
        a.fly();
        a.quack();
        a.setFly(new DoFlyBehavior());
        a.fly();
        
        System.out.println("===========BDuck===========");
        
        b.swim();
        b.fly();
        b.quack();
    }
 
}
 
=>결과
===========ADuck===========
헤엄친다.
못 날아요
못 운다.
난다.
===========BDuck===========
헤엄친다.
난다.
운다.
 
cs

 

이렇게 사용 시점에 알고리즘을 set 메소드로 주입하여 사용한다. 만약 추후에 ADuck이 나는 기능이 추가된다면 위 코드와 같이 날수 있는 구현 클래스를 주입해주기만 하면 ADuck은 날 수 있게 된다. 즉, ADuck은 행동을 인터페이스로 의존만 하고 있고(구성,composition) 구체적인 행동을 몰라도 되는 것이다.

posted by 여성게
:

네이버의 d2 블로그에 Java Hashmap 동작에 대해 아주 상세히 설명한 자료가 있어 참조해보았다. 해시맵에 대해 아주 상세하게 작성한 글이라 알아두면 아주 좋을 것 같다.

 

참조 : https://d2.naver.com/helloworld/831311

 

Java HashMap은 어떻게 동작하는가?

이 글은 Java 7과 Java 8을 기준으로 HashMap이 어떻게 구현되어 있는지 설명합니다. HashMap 자체의 소스 코드는 Oracle JDK나 OpenJDK나 같기 때문에, 이 글이 설명하는 HashMap 구현 방식은 Oracle JDK와 OpenJDK 둘 모두에 해당한다고 할 수 있습니다. Java가 아닌 다른 언어를 주로 사용하는 개발자라 하더라도, Java의 HashMap이 현재 어떻게 구현되어 있고, 어떻게 발전되었는지 알면 라이브러리나 프레임워크 구현에 대한 혜안을 얻을 수 있을 것이라고 기대합니다.

HashMap은 Java Collections Framework에 속한 구현체 클래스입니다. Java Collections Framework는 1998년 12월에 발표한 Java 2에서 정식으로 선보였습니다. Map 인터페이스 자체는 Java 5에서 Generic이 적용된 것 외에 처음 선보인 이후 변화가 없지만, HashMap 구현체는 성능을 향상시키기 위해 지속적으로 변화해 왔습니다.

이 글에서는 어떤 방식으로 HashMap 구현체의 성능을 향상시켰는지 소개합니다. 구체적으로 다루는 내용은 Amortized Constant Time을 위하여 어떻게 해시 충돌(hash collision) 가능성을 줄이고 있는가에 대한 것입니다.

HashMap과 HashTable

이 글에서 말하는 HashMap과 HashTable은 Java의 API 이름이다. HashTable이란 JDK 1.0부터 있던 Java의 API이고, HashMap은 Java 2에서 처음 선보인 Java Collections Framework에 속한 API다. HashTable 또한 Map 인터페이스를 구현하고 있기 때문에 HashMap과 HashTable이 제공하는 기능은 같다. 다만 HashMap은 보조 해시 함수(Additional Hash Function)를 사용하기 때문에 보조 해시 함수를 사용하지 않는 HashTable에 비하여 해시 충돌(hash collision)이 덜 발생할 수 있어 상대으로 성능상 이점이 있다. 보조 해시 함수가 아니더라도, HashTable 구현에는 거의 변화가 없는 반면, HashMap은 지속적으로 개선되고 있다. HashTable의 현재 가치는 JRE 1.0, JRE 1.1 환경을 대상으로 구현한 Java 애플리케이션이 잘 동작할 수 있도록 하위 호환성을 제공하는 것에 있기 때문에, 이 둘 사이에 성능과 기능을 비교하는 것은 큰 의미가 없다고 할 수 있다.

HashMap과 HashTable을 정의한다면, '키에 대한 해시 값을 사용하여 값을 저장하고 조회하며, 키-값 쌍의 개수에 따라 동적으로 크기가 증가하는 associate array'라고 할 수 있다. 이 associate array를 지칭하는 다른 용어가 있는데, 대표적으로 Map, Dictionary, Symbol Table 등이다.

예제 1 HashTable과 HashMap의 선언부

associative array를 지칭하기 위하여 HashTable에서는 Dictionary라는 이름을 사용하고, HashMap에서는 그 명칭이 그대로 말하듯이 Map이라는 용어를 사용하고 있다. 

map(또는 mapping)은 원래 수학 함수에서의 대응 관계를 지칭하는 용어로, 경우에 따라서는 함수 자체를 의미하기도 한다. 즉 HashMap이란 이름에서 알 수 있듯이, HashMap은 키 집합인 정의역과 값 집합인 공역의 대응에 해시 함수를 이용한다. 

그림 1 함수에서의 사상(map)

해시 분포와 해시 충돌

동일하지 않은 어떤 객체 X와 Y가 있을 때, 즉 X.equals(Y)가 '거짓'일 때 X.hashCode() != Y.hashCode()가 같지 않다면, 이때 사용하는 해시 함수는 완전한 해시 함수(perfect hash functions)라고 한다(

: S는 모든 객체의 집합, h는 해시 함수). 

Boolean같이 서로 구별되는 객체의 종류가 적거나, Integer, Long, Double 같은 Number 객체는 객체가 나타내려는 값 자체를 해시 값으로 사용할 수 있기 때문에 완전한 해시 함수 대상으로 삼을 수 있다. 하지만 String이나 POJO(plain old java object)에 대하여 완전한 해시 함수를 제작하는 것은 사실상 불가능하다. 

적은 연산만으로 빠르게 동작할 수 있는 완전한 해시 함수가 있다고 하더라도, 그것을 HashMap에서 사용할 수 있는 것은 아니다. HashMap은 기본적으로 각 객체의 hashCode() 메서드가 반환하는 값을 사용하는 데, 결과 자료형은 int다. 32비트 정수 자료형으로는 완전한 자료 해시 함수를 만들 수 없다. 논리적으로 생성 가능한 객체의 수가 232보다 많을 수 있기 때문이며, 또한 모든 HashMap 객체에서 O(1)을 보장하기 위해 랜덤 접근이 가능하게 하려면 원소가 232인 배열을 모든 HashMap이 가지고 있어야 하기 때문이다. 

따라서 HashMap을 비롯한 많은 해시 함수를 이용하는 associative array 구현체에서는 메모리를 절약하기 위하여 실제 해시 함수의 표현 정수 범위 

보다 작은 M개의 원소가 있는 배열만을 사용한다. 따라서 다음과 같이 객체에 대한 해시 코드의 나머지 값을 해시 버킷 인덱스 값으로 사용한다. 

예제 2 해시를 사용하는 associative array 구현체에서 저장/조회할 해시 버킷을 계산하는 방법

이 코드와 같은 방식을 사용하면, 서로 다른 해시 코드를 가지는 서로 다른 객체가 1/M의 확률로 같은 해시 버킷을 사용하게 된다. 이는 해시 함수가 얼마나 해시 충돌을 회피하도록 잘 구현되었느냐에 상관없이 발생할 수 있는 또 다른 종류의 해시 충돌이다. 이렇게 해시 충돌이 발생하더라도 키-값 쌍 데이터를 잘 저장하고 조회할 수 있게 하는 방식에는 대표적으로 두 가지가 있는데, 하나는 Open Addressing이고, 다른 하나는 Separate Chaining이다. 이 둘 외에도 해시 충돌을 해결하기 위한 다양한 자료 구조가 있지만, 거의 모두 이 둘을 응용한 것이라고 할 수 있다. 

그림 2 Open Addressing과 Separate Chaining 구조

Open Addressing은 데이터를 삽입하려는 해시 버킷이 이미 사용 중인 경우 다른 해시 버킷에 해당 데이터를 삽입하는 방식이다. 데이터를 저장/조회할 해시 버킷을 찾을 때에는 Linear Probing, Quadratic Probing 등의 방법을 사용한다. 

Separate Chaining에서 각 배열의 인자는 인덱스가 같은 해시 버킷을 연결한 링크드 리스트의 첫 부분(head)이다. 

둘 모두 Worst Case O(M)이다. 하지만 Open Addressing은 연속된 공간에 데이터를 저장하기 때문에 Separate Chaining에 비하여 캐시 효율이 높다. 따라서 데이터 개수가 충분히 적다면 Open Addressing이 Separate Chaining보다 더 성능이 좋다. 하지만 배열의 크기가 커질수록(M 값이 커질수록) 캐시 효율이라는 Open Addressing의 장점은 사라진다. 배열의 크기가 커지면, L1, L2 캐시 적중률(hit ratio)이 낮아지기 때문이다. 

Java HashMap에서 사용하는 방식은 Separate Channing이다. Open Addressing은 데이터를 삭제할 때 처리가 효율적이기 어려운데, HashMap에서 remove() 메서드는 매우 빈번하게 호출될 수 있기 때문이다. 게다가 HashMap에 저장된 키-값 쌍 개수가 일정 개수 이상으로 많아지면, 일반적으로 Open Addressing은 Separate Chaining보다 느리다. Open Addressing의 경우 해시 버킷을 채운 밀도가 높아질수록 Worst Case 발생 빈도가 더 높아지기 때문이다. 반면 Separate Chaining 방식의 경우 해시 충돌이 잘 발생하지 않도록 '조정'할 수 있다면 Worst Case 또는 Worst Case에 가까운 일이 발생하는 것을 줄일 수 있다(여기에 대해서는 "보조 해시 함수"에서 설명하겠다).

예제 3 Java 7에서의 해시 버킷 관련 구현

Separate Chaining 방식을 사용하기 때문에, Java 7에서의 put() 메서드 구현은 예제 4에서 보는 것과 같다. 

예제 4 put() 메서드 구현

그러나 Java 8에서는 예제 4에서 볼 수 있는 것보다 더 발전된 방식을 사용한다.

Java 8 HashMap에서의 Separate Chaining

Java 2부터 Java 7까지의 HashMap에서 Separate Chaining 구현 코드는 조금씩 다르지만, 구현 알고리즘 자체는 같았다. 만약 객체의 해시 함수 값이 균등 분포(uniform distribution) 상태라고 할 때, get() 메서드 호출에 대한 기댓값은 

이다. 그러나 Java 8에서는 이보다 더 나은 

을 보장한다. 데이터의 개수가 많아지면, Separate Chaining에서 링크드 리스트 대신 트리를 사용하기 때문이다.

데이터의 개수가 많아지면 

 

의 차이는 무시할 수 없다. 게다가 실제 해시 값은 균등 분포가 아닐뿐더러, 설사 균등 분포를 따른다고 하더라도 birthday problem이 설명하듯 일부 해시 버킷 몇 개에 데이터가 집중될 수 있다. 그래서 데이터의 개수가 일정 이상일 때에는 링크드 리스트 대신 트리를 사용하는 것이 성능상 이점이 있다.

링크드 리스트를 사용할 것인가 트리를 사용할 것인가에 대한 기준은 하나의 해시 버킷에 할당된 키-값 쌍의 개수이다. 예제 5에서 보듯 Java 8 HashMap에서는 상수 형태로 기준을 정하고 있다. 즉 하나의 해시 버킷에 8개의 키-값 쌍이 모이면 링크드 리스트를 트리로 변경한다. 만약 해당 버킷에 있는 데이터를 삭제하여 개수가 6개에 이르면 다시 링크드 리스트로 변경한다. 트리는 링크드 리스트보다 메모리 사용량이 많고, 데이터의 개수가 적을 때 트리와 링크드 리스트의 Worst Case 수행 시간 차이 비교는 의미가 없기 때문이다. 8과 6으로 2 이상의 차이를 둔 것은, 만약 차이가 1이라면 어떤 한 키-값 쌍이 반복되어 삽입/삭제되는 경우 불필요하게 트리와 링크드 리스트를 변경하는 일이 반복되어 성능 저하가 발생할 수 있기 때문이다.

예제 5 Java 8 HashMap의 TREEIFY_THRESHOLD와 UNTREEIFY_THRESHOLD

Java 8 HashMap에서는 Entry 클래스 대신 Node 클래스를 사용한다. Node 클래스 자체는 사실상 Java 7의 Entry 클래스와 내용이 같지만, 링크드 리스트 대신 트리를 사용할 수 있도록 하위 클래스인 TreeNode가 있다는 것이 Java 7 HashMap과 다르다.

이때 사용하는 트리는 Red-Black Tree인데, Java Collections Framework의 TreeMap과 구현이 거의 같다. 트리 순회 시 사용하는 대소 판단 기준은 해시 함수 값이다. 해시 값을 대소 판단 기준으로 사용하면 Total Ordering에 문제가 생기는데, Java 8 HashMap에서는 이를 tieBreakOrder() 메서드로 해결한다.

예제 6 Java 8 HashMap의 Node 클래스

해시 버킷 동적 확장

해시 버킷의 개수가 적다면 메모리 사용을 아낄 수 있지만 해시 충돌로 인해 성능상 손실이 발생한다. 그래서 HashMap은 키-값 쌍 데이터 개수가 일정 개수 이상이 되면, 해시 버킷의 개수를 두 배로 늘린다. 이렇게 해시 버킷 개수를 늘리면 

값도 작아져, 해시 충돌로 인한 성능 손실 문제를 어느 정도 해결할 수 있다.

해시 버킷 개수의 기본값은 16이고, 데이터의 개수가 임계점에 이를 때마다 해시 버킷 개수의 크기를 두 배씩 증가시킨다. 버킷의 최대 개수는 230개다. 그런데 이렇게 버킷 개수가 두 배로 증가할 때마다, 모든 키-값 데이터를 읽어 새로운 Separate Chaining을 구성해야 하는 문제가 있다. HashMap 생성자의 인자로 초기 해시 버킷 개수를 지정할 수 있으므로, 해당 HashMap 객체에 저장될 데이터의 개수가 어느 정도인지 예측 가능한 경우에는 이를 생성자의 인자로 지정하면 불필요하게 Separate Chaining을 재구성하지 않게 할 수 있다.

예제 7 Java 7 HashMap에서의 해시 버킷 확장

해시 버킷 크기를 두 배로 확장하는 임계점은 현재의 데이터 개수가 'load factor * 현재의 해시 버킷 개수'에 이를 때이다. 이 load factor는 0.75 즉 3/4이다. 이 load factor 또한 HashMap의 생성자에서 지정할 수 있다.

임계점에 이르면 항상 해시 버킷 크기를 두 배로 확장하기 때문에, N개의 데이터를 삽입했을 때의 키-값 쌍 접근 횟수는 다음과 같이 분석할 수 있다.

즉 기본 생성자로로 생성한 HashMap을 이용하여 많은 양의 데이터를 삽입할 때에는, 최적의 해시 버킷 개수를 지정한 것보다 약 2.5배 많이 키-값 쌍 데이터에 접근해야 한다. 이는 곧 수행 시간이 2.5배 길어진다고 할 수 있다. 따라서 성능을 높이려면, HashMap 객체를 생성할 때 적정한 해시 버킷 개수를 지정해야 한다.

그런데 이렇게 해시 버킷 크기를 두 배로 확장하는 것에는 결정적인 문제가 있다. 해시 버킷의 개수 M이 2a 형태가 되기 때문에, index = X.hashCode() % M을 계산할 때 X.hashCode()의 하위 a개의 비트만 사용하게 된다는 것이다. 즉 해시 함수가 32비트 영역을 고르게 사용하도록 만들었다 하더라도 해시 값을 2의 승수로 나누면 해시 충돌이 쉽게 발생할 수 있다.

이 때문에 보조 해시 함수가 필요하다.

보조 해시 함수

index = X.hashCode() % M을 계산할 때 사용하는 M 값은 소수일 때 index 값 분포가 가장 균등할 수 있다. 그러나 M 값이 소수가 아니기 때문에 별도의 보조 해시 함수를 이용하여 index 값 분포가 가급적 균등할 수 있도록 해야 한다. 

보조 해시 함수(supplement hash function)의 목적은 '키'의 해시 값을 변형하여, 해시 충돌 가능성을 줄이는 것이다. 이 보조 해시 함수는 JDK 1.4에 처음 등장했다. Java 5 ~ Java 7은 같은 방식의 보조 해시 함수를 사용하고, Java 8부터는 다시 새로운 방식의 보조 해시 함수를 사용하고 있다. 

예제 8 Java 7 HashMap에서의 보조 해시 함수

그런데 Java 8에서는 Java 7보다 훨씬 더 단순한 형태의 보조 해시 함수를 사용한다. 

예제 9 Java 8 HashMap에서의 보조 해시 함수

예제 9에서 볼 수 있는 것처럼, Java 8 HashMap 보조 해시 함수는 상위 16비트 값을 XOR 연산하는 매우 단순한 형태의 보조 해시 함수를 사용한다. 이유로는 두 가지가 있는데, 첫 번째는 Java 8에서는 해시 충돌이 많이 발생하면 링크드 리스트 대신 트리를 사용하므로 해시 충돌 시 발생할 수 있는 성능 문제가 완화되었기 때문이다. 두 번째로는 최근의 해시 함수는 균등 분포가 잘 되게 만들어지는 경향이 많아, Java 7까지 사용했던 보조 해시 함수의 효과가 크지 않기 때문이다. 두 번째 이유가 좀 더 결정적인 원인이 되어 Java 8에서는 보조 해시 함수의 구현을 바꾸었다. 

개념상 해시 버킷 인덱스를 계산할 때에는 index = X.hashCode() % M처럼 나머지 연산을 사용하는 것이 맞지만, M값이 2a일 때는 해시 함수의 하위 a비트 만을 취한 것과 값이 같다. 따라서 나머지 연산 대신 '1 << a – 1' 와 비트 논리곱(AND, &) 연산을 사용하면 수행이 훨씬 더 빠르다. 

String 객체에 대한 해시 함수

String 객체에 대한 해시 함수 수행 시간은 문자열 길이에 비례한다. 

때문에 JDK 1.1에서는 String 객체에 대해서 빠르게 해시 함수를 수행하기 위해, 일정 간격의 문자에 대한 해시를 누적한 값을 문자열에 대한 해시 함수로 사용했다. 

예제 10 JDK 1.1에서의 String 클래스 해시 함수

예제 10에서 볼 수 있듯이 모든 문자에 대한 해시 함수를 계산하는 게 아니라, 문자열의 길이가 16을 넘으면 최소 하나의 문자를 건너가며 해시 함수를 계산했다. 

그러나 이런 방식은 심각한 문제를 야기했다. 웹상의 URL은 길이가 수십 글자에 이르면서 앞 부분은 동일하게 구성되는 경우가 많다. 이 경우 서로 다른 URL의 해시 값이 같아지는 빈도가 매우 높아질 수 있다는 문제가 있다. 따라서 이런 방식은 곧 폐기되었고, 예제 11에서 보는 방식을 현재의 Java 8까지도 계속 사용하고 있다. 

예제 11 Java String 클래스 해시 함수

예제 11은 Horner's method를 구현한 것이다. Horner's method는 다항식을 계산하기 쉽도록 단항식으로 이루어진 식으로 표현하는 것이다. 즉 예제 11에서 계산하고자 하는 해시 값 h는 다음과 같다. 

이렇게 단항식을 재귀적으로 사용하여 다항식 연산을 표현할 수 있다. 

String 객체 해시 함수에서 31을 사용하는 이유는, 31이 소수이며 또한 어떤 수에 31을 곱하는 것은 빠르게 계산할 수 있기 때문이다. 31N=32N-N인데, 32는 25이니 어떤 수에 대한 32를 곱한 값은 shift 연산으로 쉽게 구현할 수 있다. 따라서 N에 31을 곱한 값은, (N << 5) – N과 같다. 31을 곱하는 연산은 이렇게 최적화된 머신 코드로 생성할 수 있기 때문에, String 클래스에서 해시 값을 계산할 때에는 31을 승수로 사용한다. 

Java 7에서 String 객체에 대한 별도의 해시 함수

JDK 7u6부터 JDK 7u25까지는 HashMap에 저장된 키-값 쌍이 일정 개수 이상이면 String 객체에 한하여 별도의 해시 함수를 사용할 수 있게 하는 기능이 있다. 이 기능은 JDK 7u40부터는 삭제되었고, 당연히 Java 8에도 해당 기능은 없다. 여기서 말하는 '일정 개수 이상'이나 '별도의 해시 함수 사용 여부 지정'은 JVM을 가동할 때 옵션으로 지정할 수 있다. 

예제 12 Java 7의 String에 대한 hash32() 메서드

JDK 7u6부터 JDK 7u25까지는 jdk.map.althashing.threshold 옵션을 지정하면, HashMap에 저장된 키-값 쌍이 일정 개수 이상일 때 String 객체에 String 클래스의 hashCode() 메서드 대신 sun.misc.Hashing.stringHash32() 메서드를 사용할 수 있게 했다. sun.misc.Hashing.stringHash32() 메서드는 String 클래스의 hash32() 메서드를 호출하게 한 것이고, hash32() 메서드는 MurMur 해시를 구현한 것이다. 이 MurMur 해시를 이용하여 String 객체에 대한 해시 충돌을 매우 낮출 수 있었다고 한다. 

그러나 부작용도 있다. MurMur 해시는 hash seed를 필요로 하는데, 이를 위한 것이 sun.misc.Hashing.randomHashSeed() 메서드다. 이 메서드에서는 Random.nextInt() 메서드를 사용한다. Random.nextInt() 메서드는 compare and swap 연산(이하 CAS 연산)을 사용하는 AtomicLong을 사용하는데, CAS 연산은 코어가 많을수록 성능이 떨어진다. 즉 JDK 7u6부터 등장한 String 객체에 대한 별도의 해시 함수는 멀티 코어 환경에서는 성능이 하락했고, 이런 문제로 인해 JDK 7u40부터는 해당 기능을 사용하지 않는다. 당연히 Java 8도 사용하지 않는다.

posted by 여성게
:

 

 

Java - JVM이란? JVM 메모리 구조

초심으로 돌아갈 때가 된 것 같다. 오늘 포스팅할 내용은 JVM(Java Virtual Machine)이다. 사실 지금까지는 스킬 베이스의 공부만 해왔었다. 하지만 점점 개발을 하다보니 성능이라는 것에 굉장히 큰 관심이 생겼..

coding-start.tistory.com

이전 포스팅에서는 초심으로 돌아가 JVM에 대해 포스팅을 하였습니다. 이번 포스팅은 JVM의 Heap 영역에서 이루어지는 Garbage Collection에 다루어 보려고 합니다. 본 포스팅에서 Hotspot JVM의 GC를 다룰 것입니다.

 

Garbage Collection은 무엇일까? GC란 이미 할당된 메모리에서 더 이상 사용하지 않는 메모리를 해제하는 행동을 의미한다. 사용되지 않는 메모리의 대상은 Heap과 Method Area에서 사용되지 않는 Object를 의미한다. 그리고 소스상의 close()는 Object 사용중지 의사표현일 뿐 Object를 메모리에서 삭제하겠다는 의미가 아니다. 물론 개발자는 System.GC()를 명시적으로 호출할 수 있지만 주의해야 할 것이 있다면 해당 메소드 호출은 Full GC를 수행시키는 메소드이기 때문에 Stop-the-world 시간이 길고 무거운 작업이며 또한 반드시 즉시 수행한다는 보장도 없는 메소드이기에 사용을 지향하는 메소드이다.

 

GC에 대해 자세히 다루어보기 전에 과연 GC로 인한 문제점이 무엇이 있을까?

 

GC라는 자동화 메커니즘으로 인해 프로그래머는 직접 메모리를 핸들링 할 필요가 없게 되었다. 더불어 잘못된 메모리 접근으로 인한 Crash 현상의 소지도 없어지게 되었으니 더 없이 편리한 기능이 아닐 수 없다. 그러나 GC는 명시적인 메모리 해제보다 느리며 GC 순간 발생하는 Suspend Time(Stop-the-world) 시간으로 인해 다양한 문제를 야기시킨다.(애플리케이션 동작이 멈춰 클라이언트는 한순간 애플리케이션이 멈춰보인다.)

 

Root Set과 Garbage

Garbage Collection은 말 그대로 Garbage를 모으는 작업인데 여기서 Garbage란 사용되지 않는 Object, 즉 더 이상 참조되지 않는 Object를 뜻한다. 좀 더 자세히 설명하면 Object의 사용 여부는 Root Set과의 관계로 판단하게 되는데 Root Set에서 어떤 식으로든 참조 관계가 있다면 Reachable Object라고 하며 이를 현재 사용하고 있는 Object로 간주하며 그 밖의 Object는 Unreachable Object가 된다.

 

물론 위 그림에는 없지만 참조되지만 더 이상 사용되지 않는 Object인 Memory Leak Object도 존재한다. 뒤에서 정확히 다루어볼 것이다.

 

Root Set은 더 구체적으로 아래와 같이 3가지 참조 형태를 통해 Reachable Object를 판별한다.

  1. Local variable Section, Operand Stack에 Object의 참조 정보가 있다면 Reachable Object이다.
  2. Method Area에 로딩된 클래스 중 constant pool에 있는 참조 정보를 토대로 Thread에서 직접 참조하지 않지만 constant pool을 통해 간접으로 참조되고 있는 Object는 Reachable Object이다.
  3. 아직 메모리에 남아 있으며 Native Method Area로 넘겨진 Object의 참조가 JNI 형태로 참조 관계가 있는 Object는 Reachable Object이다.

위의 3가지 경우를 제외하면 모두 GC 대상이 된다.

 

위에서 설명한 Memory Leak 객체의 구체적인 사례를 살펴 볼 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Main{
    public static void main(String args[]){
        Leak lk = new Leak();
        for(int i=0;i<9000000;i++){
            lk.addList(a);
            lk.removeStr(a);
        }
    }
}
 
class Leak{
    ArrayList lst = new ArrayList();
 
    public void addList(int a){
        lst.add("abcdefg");
    }
 
    public void removeStr(int i){
        Object obj = lst.get(i);
        obj=nulll;
    }
}
cs

위의 코드는 어떻게 될까? 아마 OutOfMemoryException이 발생할 것이다. removeStr()을 통해 참조를 null로 바꾸고 있는 객체는 List에 들어있는 객체의 참조이지만 문자열 객체 그 자체가 아니기에 List 안에 있는 문자열 객체는 그대로 남아 있을 것이다. 하지만 사용자 입장에서는 삭제했다고 생각하고 더 이상 List에 들어있는 문자열을 사용하지 않을 것이다. 이것을 바로 Memory Leak한 Object라고 한다. 다른 표현으로는 Reachable but not Live라고 표현한다.

 

GC가 수행된 메모리의 해지는 할당된 그 자리에서 이루어지며 그로 인해 Heap 메모리 단편화 문제가 발생한다.(객체를 할당할 자리는 충분하지만 자리가 듬성듬성 나있고 연속적이지 않아 객체를 할당하지 못하는 현상) JVM에서는 이러한 단편화를 해소하기 위해 compaction 같은 알고리즘을 사용한다. 하지만 compaction은 비용이 큰 작업이기에 TLAB(Thread Local Allocation Block)을 사용하여 단편화 문제를 해결하기도 한다. 이것은 뒤에서 더 자세히 다룰 것이다. 여기까지 GC는 Root Set에서 참조되지 않는 메모리 자원을 반납하는 행위. 즉, 한정된 자원인 Heap이라는 메모리 공간을 재활용하려는 목적인 것이다. 

 

Hotspot JVM의 Garbage Collection

Hotspot JVM은 기본적으로 Generational Collection 방식을 사용한다. 즉, Heap을 Object Generation 별로 Young Area와 Old Area로 구분하며 Young Area는 다시 Eden Area와 Survivor Area 두개로 구분하여 사용된다.

 

GC 메커니즘은 경험적 지식(Weak Generational Hypothesis)으로 두 가지 가설을 두고 있는데 첫째로 대부분의 객체는 생성된 후 금방 Garbage가 된다. 둘째로 Old 객체가 Young 객체를 참조할 일은 드물다라는 것이다.

 

첫번째 가설을 보면 새로 할당되는 객체가 모인 곳은 메모리 단편화가 발생할 확률이 높다고 간주한다. 메모리 할당은 기존 객체 다음 주소에 할당하게되며 Garbage는 먼저 할당된 부분에서 많이 생길 것이다. Sweep(Mark되지 않은 객체 제거)을 하게되면 메모리 단편화가 발생하게 되며 이후 Compaction처럼 비용이 높은 작업을 해야한다. 이 때문에 객체 할당만을 위한 전용공간인 Eden Area를 만들게 된 것이고 Minor GC 당시 Live한 객체를 옮기는 Survivor Area를 따로 구성한 것이다.

 

Garbage를 추적하는 부분은 Tracing 알고리즘을 사용한다. 위쪽 그림에서도 설명한 RootSet에서 참조 관계를 추적하고 참조되고 있는 객체는 Marking 처리한다. 이러한 Marking 작업도 Young 영역에 국한 되는데 Marking 작업은 메모리 Suspend 상황에서 수행되기 때문이다. 만약 Old 영역까지 Marking 작업을 수행한다면 그만큼 Suspend(Stop-the-world)시간이 길어질 것이다. 하지만 만약 Old 영역에서 Young 영역의 객체를 참조하고 있다면 어떻게 해야할까? 분명 Marking 처리를 하지 않는다고 했는데? 이런 경우를 처리하기 위하여 Card Table이라는 것이 존재한다.

 

Card Table

Card Table이란 Old 영역의 메모리를 대표하는 별도의 메모리구조이다.(실제로 Old 영역의 일부는 Card Table로 구성됨) 만약 Young 영역의 객체를 참조하는 Old 영역의 객체가 있다면 해당 Old 영역의 객체의 시작주소에 카드를 Dirty로 표시하고 해당 내용을 Card Table에 기록한다.

 

 

이후 해당 참조가 해제되면 표시한 Dirty Card도 사라지게끔 하여 참조 관계를 쉽게 파악할 수 있게 한다. 이러한 Card Table 덕분에

Minor GC 과정에서 Card Table의 Dirty Card만 검색하면 빠르게 Old 영역에서 참조되는 Young 영역의 객체를 알 수 있다. 참고로 Card Table은 Old 영역 512byte당 1byte의 공간을 차지한다고 한다.

 

TLAB(Thread-Local Allocation Buffers)

 

Garbage Collection이 발생하거나 객체가 각 영역에서 다른 영역으로 이동할 때 애플리케이션의 병목이 발생하면서 성능에 영향을 주게 된다. 즉, T1이 할당하기 전까지 T2~T5는 락이 걸려 대기하게 될 것이다. 그래서 Hotspot JVM에서는 스레드 로컬 할당 버퍼(TLAB)라는 것을 사용한다. 이를 통해 각 스레드별 메모리 버퍼를 사용하면 다른 스레드에 영향을 주지 않는 메모리 할당 작업이 가능하게 된다. 하지만 TLAB도 최초 할당 때와 할당된 TLAB이 부족하여 새로이 할당을 받을 때에는 동기화 이슈가 발생한다. 하지만 객체 Allocation 작업 횟수에 비하면 동기화 이슈가 대폭 줄기 때문에 Allocation에 걸리는 시간은 상당히 줄어든다.

 

여기서 하나 더 그림의 특징이 있다. 스레드가 객체를 할당할때 메모리 주소의 맨 뒷 공간에 할당하는 것이 보인다. 이것은 Bump-the-pointer라는 기술로써 어딘가에 주소 맨 뒷 자리 정보를 캐싱해 놓고 매번 마지막 주소에 할당하는 방법인 것이다.

 

GC 대상 및 범위

GC 대상 Area는 Young, Old Generation과 Java 1.7 기준 Permanent Area인데 Hotspot JVM은 각 Generation 별로 각각 GC를 수행한다.(Java 1.8에서도 Static 변수, 상수 등은 Heap 메모리에 할당되므로 GC 대상이 된다.) Young Generation에서 발생하는 GC를 Minor GC라고 한다. Young Generation은 빈번하게 GC가 수행되는 영역이며 성숙된 Object는 Old Generation으로 Promotion된다. 여기서 성숙된 Object라 함은 특정 횟수 이상 참조되어 기준 Age를 초과한 Object를 말한다. Promotion 과정 중 Old Generation의 메모리도 충분하지 않은 경우면 GC를 수행하는 데, 이는 Full GC(Major GC)라고 한다. 해당 GC는 Young 영역보다 비교적 큰 메모리를 GC하는 과정이므로 Suspend(Stop the world) 시간이 긴 GC 과정이다. Perm 영역에서도 메모리가 부족할 수 있는 데, 이는 너무 많은 수의 클래스가 로딩되어 여유 공간이 없어진 경우이다. 이 경우에도 Young+Old에 여유공간이 많다고 해도 Full GC가 발생한다.

 

GC 관련 옵션

 

옵션 상세 설명
-Client Client Hotspot VM으로 구동
-Server Server Hotspot VM으로 구동

-Xms<Size>

-Xmx<Size>

Yound Generation의 최소,최대 크기를 설정
-Xss<Size> Stack 사이즈 설정
-XX:MaxNewSize=<Size>

Young Generation의 최대 크기 설정

1.4 버전 이후 NewRatio에 따라 자동 계산

<Java 1.7>

-XX:PermSize

-XX:MaxPermSize

Perm 영역의 초기 크기와 최대 크기(Default 64MB)를 설정

<Java 1.8>

-XX:MetaspaceSize

-XX:MaxMetaspaceSize

Metaspace 영역의 초기 크기와 최대 크기(Default OS 최대 자원 사용) 설정
-XX:SurvivorRatio=<value> 값이 n 이면 n:1:1(Eden:Survivor1:Survivor2=n:1:1) default 8
-XX:NewRatio=<value>

값이 n 이면 Young:Old 비율이 1:n

Client VM은 기본값이 8이며, Server VM은 2이다. 

Intel 계열 CPU를 사용하면 기본값은 12이다.

-XX:TargetSurvivorRatio=<value>

Minor GC는 Eden 영역 뿐만 아니라 Survivor 영역이 Full나도 발생한다. 해당 설정은 Survivor가 Minor GC를 유발하는 비율을 말하며 default=50이다.

즉, Survivor 영역이 50%만 차도 Minor GC가 발생한다.

높게 지정한다면 Survivor 영역 활용도가 높아져 Minor GC 횟수를 조금 줄일 수 있다.

-XX:MinHeapFreeRatio=<percent> 전체 Heap 대비 Free space가 지정된 수치 이하면 Heap을 -Xmx로 지정된 메모리까지 확장한다.(default=40)
-XX:MaxHeapFreeRatio=<percent> 전체 Heap 대비 Free space가 지정된 수치 이상이면 Heap을 -Xms까지 축소한다.(default=70)
-XX:MaxTenuringThreshold=<value> Value만큼 Survivor 영역을 이동하면 그 다음 Minor GC 시간때 Old 영역으로 Promotion한다.
-XX:+DisableExplicitGC System.gc() 함수를 통한 수동 Full GC를 방지한다.

 

Garbage Collector 종류

업무 요건이 복잡해지고 시스템이 점점 거대해지면서 최근에는 자연스럽게 JVM Heap Size 설정이 커지게 되었다. 그러나 그로 인해 GC로 인한 Suspend 현상도 이슈로 대두되게 되었다. Hotspot JVM은 기본적으로 Generation 별로 다른 Garbage Collection 알고리즘을 선택할 수 있다. 아래는 Java 6까지의 Hotspot JVM의 Garbage Collector를 정리한 표이다.

 

Garbage Collector Option

Young Generation 

Collector 알고리즘

Old Generation

Collector 알고리즘

Serial Collector -XX:+UseSerialGC Serial Serial Mark-Sweep-Compact
Parallel Collector -XX:+UseParallelGC Parallel Scavenge Serial Mark-Sweep-Compact
Parallel Compacting Collector -XX:+UseParallelOldGC Parallel Scavenge Parallel Mark-Sweep-Compact
CMS Collector -XX:+UseConcMarkSweepGC Parallel Concurrent Mark-Sweep
G1 Collector -XX:+UseG1GC

Young GC(Evacuation Pause)

Concurrent Mark - Marking

Current Mark - Remarking

Old Region Reclaim - Remarking

Old Region Reclaim - Evacuation Pause

Compaction Phase

 

  • Serial Collector : Client VM의 기본 collector로 한 개의 스레드가 serial로 수행한다.
  • Parallel Collector : 모든 자원을 투입하여 Garbage Collection을 빨리 끝내는 전략으로 대용량 Heap에 적합하다.
  • CMS Collector : Suspend Time을 분산시켜 체감 Pause Time을 줄이고자 할 때 사용한다.
  • G1 Collector : Generation을 물리적으로 구분하지 않고 Region이라는 단위로 쪼개서 관리를 한다. G1 Collector는 이론적으로 거의 Real Time에 가깝게 Suspend Time을 감소시킬 수 있다고 한다.

다양한 GC 알고리즘이 있다. 하지만 여기서 중요한 것은 뭐가 좋다 나쁘다 할 수 없다는 것이다. 애플리케이션의 성격 그리고 현 시스템이 어떠한 시스템이냐에 따라 각 알고리즘의 성능이 달라지기 때문이다. 많은 테스트를 통해 나의 애플리케이션이 어떠한 GC 알고리즘에 더 잘 맞을 지를 선택하는 것이 중요하다. 각 GC 알고리즘에 대해 살펴보자.

 

Serial Collector

Young / Old Generation 모두 Serial로 Single CPU를 사용한다. 즉 1 개의 스레드를 가지고 GC를 수행한다. Client VM의 기본 컬렉터이며 현재는 거의 사용되지 않는 컬렉터이다.

 

Parallel Collector

이 방식은 throughput collector로도 알려진 방식이다. 이 방식의 목표는 다른 CPU가 대기 상태로 남아 있는 것을 최소화 하자는 것이다. Serial Collector와 달리 Young 영역에서의 컬렉션을 병렬로 처리한다. Old 영역은 Serial Collector와 동일한 방식이다. 이는 많은 CPU 자원을 사용하는 반면에 GC의 부하를 줄이고 애플리케이션의 처리량을 증가시킬 수 있다.

 

이는 다수의 스레드가 동시에 GC를 수행하며 적용범위는 Young Area에 국한된다. Old Area는 Serial Mark-Sweep-Compact 알고리즘이 사용된다. 이 컬렉터는 Server VM의 JVM에서 기본 Collector이며 Client VM에서도 옵션 설정으로 사용이 가능하다. 단 CPU가 1개라면 해당 옵션은 무시된다.

 

Mark-Sweep-Compact : 우선 참조관계에 있는 Object를 마킹처리한다.(Mark) 그 이후 마킹처리되지 않은 Object를 자원 해제한다.(Sweep) 자원 해제 후 Heap 메모리의 단편화 해결을 위하여 Compact 작업을 통해 사용되고 있는 메모리와 사용되지 않는 메모리로 구분하여 몰아준다.

 

 

Eden, Survivor Area의 Live Object Copy 작업을 다수의 스레드가 동시에 수행한다.(Suspend 현상 발생) 하지만 투입한 자원만큼 Suspend 시간을 단축할 수 있다. 하지만 여기서 생각해야할 문제가 있다. 다수의 스레드가 Minor GC를 수행하다가 두 개이상의 스레드가 Old Generation 영역에 Promotion 작업을 하게 될 때가 있다. 시간의 간격이 있다면 괜찮지만 만약 동시에 Promotion을 한다면? Corruption이 발생할 수 있다. 하지만 Hotspot JVM은 이러한 문제를 해결하기 위하여 PLAB(Parallel Allocation Buffer)라는 Promotion Buffer를 두어 이러한 문제를 해결하고 있다.

 

 

PLAB(Parallel Allocation Buffer)

위의 그림을 보면 Old Generation으로 Promotion 작업을 두 개의 스레드가 동시에 수행하여 충돌이 나는 상황이 발생하였다. 이러한 문제를 해결하기 위하여 Minor GC를 수행하는 스레드마다 Old Generation의 일정부분을 PLAB이라는 버퍼를 할당하여 충돌상황을 피하고 있다. 하지만 이것도 문제가 아예 없는 것은 아니다. PLAB 때문에 어쩔 수 없이 Old Generation에 메모리 단편화가 생기곤 한다.

 

Parallel Collector 옵션

Parallel Collector의 기본 동작 방식은 애플리케이션 수행시간 대비 GC 수행시간은 default 1%이며, 애플리케이션 수행시간의 최대 90%를 넘지 못한다. 또한 Young Area는 확장할 때 20%씩 증가하고 5%씩 감소한다. GC를 수행하게 되면 최대 Heap Size 대비 5%의 여유 공간을 확보해야 한다.

 

옵션 상세 설명
-XX:+UseParallelGC Parallel Collector 사용 옵션이다.
-XX:ParallelGCThreads=개수 Parallel GC Thread 개수를 설정한다.(기본 값은 CPU 개수)
-XX:+AlwaysTenure

설정하면 Eden Aread의 Reachable Object는 바로 Old Generation으로 Promotion 된다. Parallel Collector 사용시에만 유효하다.

-XX:MaxTenuringThreadshold=0과 같은 설정이다.

-XX:+NeverTenure Survivor Area가 꽉 차는 경우를 제외하고 Object를 Old 영역으로 Promotion 안되게 설정하는 옵션이다. Parallel Collector에서만 유효한 설정이다.(age 자체를 없애서 승격이 안되게 한다.)
-XX:MaxGCPauseMillis=값 설정값 이하로 GC의 Pause Time을 유지하라는 설정이다.
-XX:GCTimeRatio=값 애플리케이션 수행시간 대비 전체 GC의 시간비율을 말한다. 19로 설정하면 1/(1+19)=5% default는 99(1%의 GC Time Ratio)
-XX:+UseAdaptiveSizePolicy GC 회수, Generation의 각 Area 비율, free Heap Size 등 통계 정보를 바탕으로 Young, Old Generation 크기를 자동으로 설정하는 옵션이다.
-XX:YoungGenerationSizeIncrement=값 Young Generation의 확장 비율 설정 옵션(기본값은 20,20%씩 증가)
-XX:AdaptiveSizeDecrementScaleFactor=값

Heap의 Growing Percent 대비 Shrink를 수행할 Ratio 의미 값이 4이고 Heap growing percent가 20 이면 20/4=5%가 된다.

기본값은 4이므로 20/4=5%가 된다.

-XX:GCHeapFreeLimit=값 GC로 확보해야 하는 최소 Free Space를 설정한다. 값은 최대 Heap Size에 대한 비율을 의미한다. Default는 5(%) Out Of Memory Exception 방지를 위한 설정이다.
-XX:MaxHeapFreeRatio=<percent> 전체 Heap 대비 Free Space가 지정 수치 이상이면 -Xms까지 축소하는 옵션이다(기본값 70)

 

CMS Collector

이 방식은 low-latency-collector로도 알려져 있으며, 힙 메모리 영역의 크기가 클 때 적합하다. CMS Collector는 Suspend Time을 분산하여 응답시간을 개선한다. 비교적 자원이 여유 있는 상태에서 GC의 Pause Time을 줄이는 목적이며 Size가 큰 Long Live Object가 있는 경우 가장 적합하다. Young Area에는 Parallel Copy 알고리즘, Old Area에는 Concurrent Mark-Sweep 알고리즘이 사용된다. Young 영역의 GC 알고리즘은 크게 다른 방식과 차이가 없다. 크게 차이가 나는 부분은 Old 영역의 알고리즘이다.

 

Old Area의 Concurrent Mark-Sweep 알고리즘

 

 

위의 그림을 보면 Serial Mark-Sweep-Compact Collector의 알고리즘과 비교하여 Suspend 시간을 분산하고 있는 것이 보인다. 

 

  • Initial Mark Phase 단계 : Single Thread만 사용한다. Suspend 시간이 있으며 애플리케이션에서 직접 참조되는 Live Object만 구별한다. 여기서 직접 참조되는 관계란 RootSet에서 한 단계의 참조관계를 말한다. 깊이가 있는 탐색이 아니라 Suspend 시간이 짧다.
  • Concurrent Mark Phase 단계 : Single Thread만 사용한다. 애플리케이션의 Working Thread와 GC Thread가 같이 수행된다. 즉, 애플리케이션 수행이 가능하다. Initial Mark phase에서 선별된 Live Object가 참조하고 있는 모든 Object를 추적해 Live 여부를 구별한다.
  • Remark Phase 단계 : Multi Thread가 사용되며 애플리케이션이 중지된다. 이미 Marking 된 Object를 다시 추적, Live 여부 확정한다. 이 단계에서는 모든 자원을 투입한다.(많은 자원을 투자한만큼 Suspend 시간이 적다.)
  • Concurrent Sweep Phase 단계 : Single Thread만 사용한다. 애플리케이션은 수행되고 최종 Live로 판명된 Object를 제외한 Dead Object를 지운다. 단 Sweep 작업만 하고 Compaction 작업은 수행 안 한다. 항상 Compaction 작업은 Heap의 Suspend를 전제로 하는데 반복된 Sweep은 단편화를 유발한다. 때문에 Free List를 사용하여 단편화를 줄이는 노력을 한다.

 

CMS Collector를 수행하면 아래와 같은 메모리 단편화 문제가 발생한다. 해당 문제를 해결하기 위해 Free List를 이용한다.

Free List?

Promotion 할당을 할 때 Young Area에서 승격된 Object와 크기가 비슷한 Old Area의 Free space를 Free List에서 탐색하게 된다. 또한 Promotion되는 Object Size를 계속 통계화하여 Free Memory 블록들을 붙이거나 쪼개서 적절한 Size의 Free Memory Chunk에 Object를 할당한다. 그러나 이러한 작업은 GC 수행 중 Young 영역에 부담을 준다. 그 이유는 Free List에서 적절한 Chunk 크기를 찾아 Allocation 해야 되기 때문이며 시간도 오래 걸린다. 즉 이말은 Young 영역에서의 체류 시간이 길어진다는 의미이다. 그렇다고 Compaction을 적용하기에도 해당 연산은 비용이 높다. 그때그때 상황에 맞게 사용하는 것이 좋을 듯 하다.

 

CMS Collector의 Floating Garbage 문제 : Old Area

CMS Collector는 Floating Garbage 문제가 있다. 만약 Initial 단계가 아니라 Concurrent Mark 단계에서 새롭게 Old 영역에 Promotion된 Object가 있다고 생각하자. 여기에서 Concurrent Mark 단계가 끝나기 전에 새로 Promotion된 Object의 참조관계가 끊어졌다. 이러면 과연 이 Object가 GC 대상이 될까? 아니다. CMS Collector는 initial mark 단계에서 마킹된 Object만 GC 대상이 되기 때문에 Concurrent Mark 단계에 Promotion되고 참조가 끊기 Object는 다음 GC 시간때 자원 해제된다. 이러한 문제를 Floating Garbage문제라고 한다. 이러한 문제를 해결하기 위해 CMS Collector는 통계정보를 계속 수집하여 적당한 GC Scheduling 시간을 잡는다. 가장 적당한 스케줄링 시간이 Remark 단계가 Minor GC 중간 지점인 스케줄링 시간이다.

 

CMS Collector 옵션

 

-XX:+UseConcMarkSweepGC

이 플래그는 애초에 CMS 컬렉터를 활성화하기 위해 필요하다. 기본적으로, HotSpot 은 처리율 컬렉터를 대신 사용한다.

-XX:+UseParNewGC

CMS 컬렉터를 사용할때, 이 플래그는 다중 쓰레드를 사용해 young generation GC의 parallel 실행을 활성화한다. 아마도 컨셉상 young generation GC 알고리즘에서 사용되는 것과 같기때문에 처리량 컬렉터에서 봤던 -XX:+UseParallelGC 플래그를 다시 사용하지 않는다는데 놀랄 것이다. 하지만, young generation GC 알고리즘과 old generation GC 알고리즘의 상호작용이 CMS 컬렉터에서 다르며 young generation 에서의 구현은 서로 달라 결국 이 둘 플래그는 다른 것이다.

주목할 것은 현재 JVM 버전에서 -XX:+UseConcMarkSweepGC를 지정하면 자동적으로 -XX:+UseParNewGC가 활성화 된다. 결과적으로, 만일 parallel young generation GC가 매력적이지 않다면 -XX:-UseParNewGC 세팅함으로써 비활성화할 필요가 있다.

-XX:+CMSConcurrentMTEnabled

이 플래그를 지정하면, 동시적 CMS 단계는 다중 쓰레드로 동작한다. 따라서 다중 GC 쓰레드는 모든 애플리케이션 쓰레들에서 parallel에서 작동한다. 이 플래그는 이미 기본값으로 활성화된다. 만약 serial 실행이 더 바람직하다면, 하드웨어 사양을 고려했을때, 다중 쓰레드 실행은 -XX:-CMSConcurrentMTEnabled 를 통해서 비활성화될 수 있다.

-XX:ConcGCThreads

-XX:ConcGCThreads=<value> 플래그는 (이전 버전에서 -XX:ParallelCMSThreads 으로 알려진) 동시적 CMS 단계가 동작할때에 사용할 쓰레드 개수를 정의한다. 예를들어, 값이 4면 CMS 주기의 모든 동시적 단계는 4개의 쓰레드를 사용해 동작한다는 뜻이다. 비록 높은 쓰레드의 개수가 동시적 CMS 단계의 속도를 높여줄수 있지만 추가적으로 동기화 오버헤드를 발생시킨다. 따라서 특정 애플리케이션을 다룰때, CMS 쓰레드의 수의 증가가 실제로 성능향상을 가지고 오는지 그렇지 않는지를 측정해야만 한다.

만일 이 플래그를 명시적으로 지정해주지 않으면 JVM은 처리량 컬렉터에서 알려진 -XX: ParallelGCThreads 플래그 값에 기반에 기본값 parallel CMS 쓰레드 수를 계산한다. 사용된 계산 공식은 ConcGCThreads = (ParallelGCThreads + 3)/4 이다. 따라서 CMS 컬렉터에서, -XX:ParallelGCThreads 플래그는 stop-to-world GC 단계에서만 영향을 주는게 아니라 동시성 단계에서도 영향을 준다.

요약을 하자면, CMS 컬렉터 실생에 다중쓰레드 설정을 위한 몇가지 방법이 있다. 이렇게 말하는 이유는, 먼저 CMS 컬렉터의 기본 세팅값을 사용해보고 튜닝이 필요하면 먼저 측정을하도록 권고하기 때문이다. 프로덕트 시스템에서(혹은 프로덕트와 유사한 테스트 시스템에서) 측정이 애플리케이션의 목표로하는 일시 정지 시간에 도달하지 않았다면 이러한 플래그를 통한 GC 튜닝은 고려해볼만 하다.

-XX:CMSInitiatingOccupancyFraction

처리량 컬렉터는 힙이 꽉 찼을때만 GC 주기를 시작한다. 예를들어, 새로운 객체를 할당할 공간이 없거나 객체를 old generation으로 승격시킬 공간이 없을때. CMS 컬렉터에서는 동시적 GC 동안에도 애플리케이션이 동작중여야하기 때문에 오랜시간을 기다리면 안된다. 따라서, 애플리케이션이 out of memory 되기전에 GC 주기를 끝내기 위해서 CMS 컬렉터는 처리량 컬렉터보다 일찍 GC 주기를 시작할 필요가 있다.

서로 다른 애플리케이션이면 객체 할당 패턴도 서로 다르며, JVM은 실제 객체 할당 및 비할당에 대한 런타임 통계를 수집하고 CMS GC 주기를 언제 시작할지 결정할때 사용한다. 이 과정을 부트스랩기, JVM은 첫번째 CMS 실행을 시작할때 힌트를 획득한다. 그 힌트는 -XX:CMSInitiatingOccupancyFraction=<value> 통해서 퍼센트로 old generation 힙 공간의 사용량으로 표시되어 지정될 수 있다. 예를들어 값이 75면 old generation의 75%를 획득했을때에 첫번째 CMS 주기를 시작하라는 의미다. 전통적으로 CMSInitiatingOccupancyFraction 의 기본값은 68 이다. (오랫동안 경험을 통해 얻은 수치다)

-XX+UseCMSInitiatingOccupancyOnly

우리는 런타임 통계에 기반해 CMS 주기를 시작할지 결정하지 않도록 JVM 에게 지시하기 위해 -XX+UseCMSInitiatingOccupancyOnly 를 사용할 수 있다. 대신, 이 플래그가 활성화된 때에, JVM은 첫음 한번만이 아닌 매번 CMS주기에서 CMSInitiatingOccupancyFraction 값을 사용한다. 그러나, 대부분의 경우 JVM이 우리 인간보다 GC 의사결정을 좀 더 잘한다는 것을 염두에 둬야 한다. 따라서, 이것은 합당한 이유가(ex, 측정을 통해 데이터를 얻었을때에) 있을때 뿐만아니라 애플리케이션에 의해서 생성된 객체의 생명주기에대해서 실제로 좋은 지식을 가지고 있는 경우에 이 플래그를 사용해야 한다.

-XX:+CMSClassUnloadingEnabled

처리량 컬렉터와 비교해, CMS 컬렉터는 기본적으로 permanent generation 에 GC를 수행하지 않는다. 만약 permanent generation GC가 필요하다면, -XX:+CMSClassUnloadingEnabled 통해서 활성화될 수 있다. 이전버전의 JVM에서는 추가적으로 -XX:+CMSPermGenSweepingEnabled 플래그 지정이 필요했었다. 주의할 것은, 이 플래그를 지정하지 않는다하더라도, 공간이 부족하게 되면 permanent generation 의 가비지 컬렉트를 시도할 것이지만 컬렉션은 동시적으로 수행되지 않을 것이다. – 대신, 다시 한번, 전체 GC가 동작할 것이다.

-XX:+CMSIncrementalMode

이 플래그는 CMS 컬렉터의 점진적 모드(incremental mode)를 활성화 한다. 점진적 모드는 애플리케이션 쓰레드에 완전히 양도(yield)되도록 동시적 단계를 주기적으로 잠시 멈추게 한다. 결론적으로, 컬렉터는 전체 CMS 주기를 완료시키기 위해서 아주 오랜시간을 가질 것이다. 따라서, 점진적 모드 사용은 일반적인 CMS 사이클에서 동작중인 컬렉터 쓰레드가 애플리케이션 쓰레드와 아주 많이 간섭이 발생되고 있다고 측정되어질경우에 유효하다. 이것은 동시적 GC 수용을 위해 충분한 프로세스를 활용하는 현대 서버 하드웨어에서 아주 드물게 발생된다.

-XX:+ExplicitGCInvokesConcurrent 와 -XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses

오늘날, 가장 폭 널게 받아들여지는 최고의 기술은 애플리케이션에서 System.gc() 호출에 의해서 명시적으로 호출되는 GC를 차단하는 것이다. 이러한 조언이 사용되어지는 GC 알고리즘과 관련이 없다고 생각하고 있는데, CMS 컬렉터를 사용하고 있을때에 시스템 GC는 기본적으로 전체 GC를 발생시키는 아주 않좋은 이벤트이기 때문에 언급하고 넘어간다. 운좋게도, 기본 행동을 바꿀수 있는 방법이 있다. -XX:+ExplicitGCInvokesConcurrent 플래그는 JVM이 시스템 GC 요청이 있을때마다 전체GC 대신 CMS GC를 실행하도록 지시한다. 두번째 플래그인 -XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses 는 시스템 GC가 요청될 경우에 추가로 permanent generation 을 CMSGC에 포함하도록 해준다. 따라서, 이러한 플래그의 사용으로, 우리는 예기치못한 시스템GC의 stop-to-world에 대해서 우리 스스로 보호할 수 있다.

-XX:+DisableExplicitGC

CMS 컬렉터 주제를 다루는 동안, 어떤 타입의 컬렉터를 사용하던지간에 시스템 GC 요청을 완벽하게 무시하도록 JVM에게 지시하게하는 -XX:+DisableExplicitGC 플래그를 언급할 좋은 기회다. 필자에게, 이 플래그는 더 이상 생각할 필요도 없이 모든 JVM 운영에서 안전하게 지정되어질수 있도록 기본 플래그 세트에 포함된다.

 

 

Parallel Compaction Collector

Parallel Collector의 Old Area에 새로운 알고리즘이 추가된 개념으로 multi CPU에서 유리하다. Old Area의 Collection 시간을 감소시켜 효율이 증가하나 몇몇 애플리케이션이 Large System을 공유해 CPU를 확실히 점유 못하면 오히려 제 성능을 발휘하지 못한다. 이 경우 Collection의 스레드 개수를 줄여서 사용한다.(-XX:ParallelGCThreads=n 옵션으로 조정) 다시 이야기하면 Young GC는 Parallel Collector와 동일하지만 Old 영역의 GC는 다음의 3단계를 거쳐 수행된다.

 

 

 

 

  • Mark Phase 단계 : 살아 있는 객체를 식별하여 표시해 놓는 단계
  • Summary Phase 단계 : 이전에 GC를 수행하여 컴팩션된 영역에 살아 있는 객체의 위치를 조사하는 단계
  • Compact Phase 단계 : 컴팩션을 수행하는 단계로 수행 이후에는 컴팩션된 영역과 비어 있는 영역으로 나뉜다.

Mark Phase 단계

Reachable Object를 체크하며 병렬작업으로 수행된다. Old Area의 Region(논리적 구역,2KB 정도의 Chunk)이라는 단위로 균등하게 나눈다. Region이 구분되면 Collection Thread들은 각각 Region 별로 Live Object를 Marking 한다. 이 때 Live Object의 Size, 위치 정보 등 Region 정보가 갱신된다. 이 정보들은 각 Region별 통계를 낼 때 사용한다.

 

 

Summary Phase 단계

하나의 스레드가 GC를 수행, 나머지는 애플리케이션을 수행, Summary 단계가 Mark 단계 결과를 대상으로 작업을 수행한다. Region 단위이며 GC 스레드는 Region의 통계 정보로 각 Region의 Density(밀도)를 평가한다. Density는 각 Region마다 Reachable Object의 밀도를 나타내는데 Density를 바탕으로 Dense prefix를 설정하게 된다. Dense prefix는 Reachable Object의 대부분을 차지한다고 판단되는 Region이 어디까지인가 구분하는 선이며 더불어 다음 단계의 대상을 구분해 준다. Dense prefix 왼편(메모리 번지가 작은 쪽)에 있는 Region은 이 후 GC 과정에서 제외, 나머지만 Compaction이 수행된다.

 

 

Hotspot JVM에서 Compaction은 보통 Sliding Compaction을 의미하는 데 이는 Live Object를 한쪽 방향으로 이동시킨다. 오랜 기간 참조된 Object는 더 오래 Heap에 머무를 가능성이 큰데 이를 전제로 어느 지점까지의 Object는 거의 Garbage되지 않을 확률이 높고 그게 왼쪽으로 갈 수록 높아진다. 즉 Parallel Compaction Collector는 Region별 Density를 평가하고 GC를 수행할 범위를 정하는(Dense prefix) 작업을 해 Compaction의 범위를 줄여 결과적으로 GC 소요시간을 줄인다. Dense prefix 설정 작업이 끝나면 Compaction의 대상이 되는 Region의 First Live Object 주소를 찾아 저장 작업을 수행한다.

 

Compaction Phase 단계

 

 

Heap을 잠시 Suspend 상태로 만들고 Thread들이 각 Region을 할당 받아 Compaction을 수행한다. Compaction 작업은 Garbage Object를 Sweep하고 Reachable Object를 왼편으로 몰아넣는 것이다. 작업은 Destination Region, Source Region을 각각 구별하여 작업을 수행한다. 위 그림에서 Dense prefix(3번째 Region의 시작이 Dense prefix였다.-summary 단계 그림 참조) 이후 Region 들이 Destination과 Source로 구분된다. 마지막 Region은 모두 Garbage Object 들로만 되어서 별도로 구분되지 않았다. Region마다 배정된 스레드가 Garbage Object를 Sweep 한다. Source Region 외 Region에는 Garbage Object가 사라지며 Destination Region에는 Live Object만 남게 되고 스레드-1이 Source Region의 Garbage들을 수거한다. 마지막으로 Source Region에 남겨진 Live Object는 Destination Region으로 이동한다.(Compact)

 

Parallel Compaction Collector 옵션

 

옵션 상세 설명
-XX:+UseParallelOldGC Parallel Compact Collector를 사용한다는 설정
-XX:+UseParallelOldGCCompacting

Parallel Compaction Collector 사용 시 설정한다.

Parallel Compaction 사용 여부를 설정한다.(기본값 true)

-XX:+UseParallelDensePrefixUpdate Summary phase 단계에서 Dense prefix를 매번 갱신 할지에 대한 설정이다.(기본값 true)

 

Garbage First Collector(G1GC)-(추후 정리)

CMS Collector에 비해 Pause 시간이 개선되었고 예측 가능한 게 장점이다. Young Area에 집중하면 효율이 좋으나 CMS처럼 Old Area의 단편화가 있으며 Free List 사용의 문제점과 Suspend 시간이 길어지는 현상등이 있다. G1은 물리적 Generation 구분을 없애고 전체 Heap을 1M 단위 Region으로 재편한다. Parallel Compaction Collector의 Region 단위 작업과 Incremental의 Train 알고리즘을 섞어 놓은 느낌이다. Mark 단계에서 최소한의 Suspend 시간으로 Live Object를 골라내는 것은 Parallel Compaction Collector와 비슷하다. Region 별로 순차적인 작업이 진행되고 Remember set을 이용한다. Garbage First 라는 의미는 Garbage로만 꽉 찬 Region부터 Collection을 시작한다는 의미로 발견 되자 마자 즉각 Collection한다. Garbage Object가 대부분인 Region의 경우 Live Object는 Old Region에서 다른 Old Region으로 Compaction이 이루어진다. G1 Collector에서 Young, Old Area는 물리적 개념이 아니고 Object가 Allocation되는 Region의 집합을 Young Generation, Promotion되는 Region의 집합을 Old Generation이라고 한다.

 

G1 Collector가 Region 내에 참조를 관리하는 방법은 Remember set을 이용한다. 전체 Heap의 5% 미만 공간을 각 Region의 참조정보를 저장하는 Remember set으로 할당된다. Remember set은 Region 외부에서 들어오는 참조 정보를 가지고 있다. 이로 인해 Marking 작업 시 trace 일량을 줄여줘 GC 효율을 높히게 된다.

 

 

기본적으로 Allocation 때 메모리 압박이 생기거나 임계값이 초과될 때마다 GC가 발생한다.

 

Young GC(Evacuation Pause)

GC는 Young Area부터 시작하며 Minor GC와 동일한 개념이다. Suspend Time이 존재하며 Multi Thread가 작업한다. Live Object는 Age에 맞게 Survivor Region, Old Region으로 Copy되며 기존 공간은 해지된다. 이후 새로운 Object가 할당되는 Young Region은 Survivor Region과 그 근처에 비어있는 Region이 된다.

 

Concurrent Mark phase(Mark -> Remark) : Old Area GC 시작

 

Marking 단계 : Single Thread로 수행하며 이전 단계 때 변경된 정보를 바탕으로 빠르게 Initial Mark를 진행한다.

Remarking 단계 : Suspend가 있고 전체 스레드가 동시에 작업한다. 각 Region마다 Reachable Object의 Density를 계산 한 후에 Garbage Region은 다음 단계로 안 넘어가고 바로 해지된다.

 

Concurrent Mark phase에서는 snapshot-at-the-beginning(SATB) Marking 알고리즘을 사용한다. 이는 GC를 시작할 당시 참조를 기준으로 모든 Live Object의 참조를 추적하는 방식이다. 단 Mark 작업은 Concurrent phase이므로 참조가 계속 변경된다. G1 Collector는 위 작업을 위해 write barrier를 이용, 각 참조의 생성/분리를 기록한다. 이 기록은 GC 시작 시 만들어진 snapshot과 비교되어 Marking 작업을 빠르게 수행한다.

 

Old Region reclaim phase(Remark -> Evacuation)

 

 

Remark 단계 : concurrent 작업, Multi Thread 방식으로 수행한다. GC를 위해 Live Object의 비율이 낮은 몇 개의 Region을 골라낸다.

Evacuation Pause 단계 : 독특하게 Young Area의 GC를 포함, 앞의 Remark 단계에서 골라낸 Old Region은 

 

아직 작성중 ㅠㅠ..

posted by 여성게
:

초심으로 돌아갈 때가 된 것 같다. 오늘 포스팅할 내용은 JVM(Java Virtual Machine)이다. 사실 지금까지는 스킬 베이스의 공부만 해왔었다. 하지만 점점 개발을 하다보니 성능이라는 것에 굉장히 큰 관심이 생겼다. Java의 성능에 핵심인 JVM 튜닝을 다루어 보기 전에 우선 JVM이 무엇인지 알아보자.

 

JVM이란?

JVM(Java Virtual Machine)을 어떻게 정의할 것인가? 우선 원어를 먼저 알아보자. 처음의 "Java"는 프로그램 언어인 Java를 의미한다. 그리고 Virtual Machine은 물리적인 형태가 아닌 소프트웨어로서의 개념을 의미한다. 즉, 소프트웨어적으로 Java 언어를 실행시키기 위한 Machine을 말하는 것이다. 결국 JVM은 정의된 스펙(벤더사마다 다른 JVM 구현을 가진다. 하지만 표준은 존재.)을 구현한 하나의 독자적인 프로세스 형태로 구동되는 Runtime Instance라고 할 수 있다. 따라서 JVM의 역할은 개발자들이 작성한 Java 프로그램을 실행시키는 하나의 데몬이라고 볼 수 있는 것이다.

 

아래그림은 JVM의 내부구조를 보여준다.

 

 

  • Java Source : 사용자가 작성한 Java 코드이다(.java)
  • Java Compiler : Java Source 파일을 JVM이 해석할 수 있는 Java Byte Code로 변경한다.
  • Java Byte Code : Java Compiler에 의해 컴파일된 결과물이다.(.class)
  • Class Loader : JVM 내로 .class 파일들을 로드하여 Runtime Data Areas에 배치한다.
  • Execution Engine : 로드된 클래스의 Bytecode를 해석(Interpret)한다.
  • Runtime Data Area : JVM이라는 프로세스가 프로그램을 수행하기 위해 OS에서 할당 받은 메모리 공간이다.

 

 

Java코드를 JVM으로 실행시키는 과정을 간단하게 설명하면, 자바 컴파일러에 의해 자바코드가 클래스 파일로 컴파일된 후 클래스로더로 해당 클래스 파일들을 로드 시킨다. 이후 로딩된 바이트코드를 실행엔진을 이용하여 해석(Interpret)하여 런타임 데이터 영역에 배치시킨다. 하지만 매번 인터프리터하여 한줄씩 해석하는 데에는 시간이 오래 걸리기 때문에 JIT(Just In Time) 컴파일러를 이용한다. JIT 컴파일러를 이용하지 않는다면 바이트코드를 사용할 때마다 인터프리터해서 사용해야 하기 때문에 실행시간이 굉장히 느리다. 하지만 적절한 시점에 JIT 컴파일러가 바이트코드를 컴파일하여 네이트브 코드로 캐싱해놓는 것이다. 여기서 컴파일한다는 것은 인터프리터처럼 한줄 단위로 컴파일하는 것이 아니라 실행하는 전체 코드를 컴파일하고 캐시하므로 이 또한 시간이 꽤 걸린다. 하지만 한번 컴파일된 이후 캐싱되니 그 이후로는 빠른 실행을 보장할 것이다.(한번 실행되고 말 것들은 인터프리터 하고 주기적으로 자주 수행되는 것들은 체킹을 통해 일정 횟수가 넘으면 JIT 컴파일러로 전체 컴파일한다.) 이렇게 해석된 녀석들은 Runtime Data Areas에 배치한다.

 

그렇다면 Runtime Data Areas는 어떠한 메모리 구조를 갖고 있을까? 한번 살펴보자!

 

 

  • Method Areas : 클래스, 변수, Method, static 변수, 상수 정보 등이 저장되는 영역이다.(모든 Thread가 공유한다.)
  • Heap Area : new 명령어로 생성된 인스턴스와 객체가 저장되는 구역이다.(Garbage Collection 이슈는 이 영역에서 일어나며, 모든 Thread가 공유한다.)
  • Stack Area : Java Method 내에서 사용되는 값들(매개변수, 지역변수, 리턴값 등)이 저장되는 구역으로 메소드가 호출될 때 LIFO로 하나씩 생성되고, 메소드 실행이 완료되면 LIFO로 하나씩 지워진다.(각 Thread별로 하나씩 생성된다.)
  • PC Register : CPU의 Register와 역할이 비슷하다. 현재 수행 중인 JVM 명령의 주소값이 저장된다.(각 Thread별로 하나씩 생성된다.)
  • Native Method Stack : 다른 언어(C/C++ 등)의 메소드 호출을 위해 할당되는 구역으로 언어에 맞게 Stack이 형성되는 구역이다.

Java Heap(Hotspot JVM의 Heap)

Java의 메모리 구조는 Heap이 전부는 아니다. Thread 공유의 정보는 Stack에 저장이 되고 Class나 Method 정보, Bytecode등은 Method Areas에 저장된다. Java Heap은 Instance, Array 객체 두 가지 종류만 저장되는 공간이다. 모든 Thread들에 의해 공유되는 영역이다. 같은 애플리케이션을 사용하는 Thread 사이에서는 공유된 Heap Data를 이용할 때 동기화 이슈가 발생할 수 있다.

JVM은 Java Heap에 메모리를 할당하는 메소드(ex.new 연산)만 존재하고 메모리 해제를 위한 어떤 Java Code를 직접적으로 프로그래머가 명시하지 않는다.(물론 있긴하지만 사용하지 않는 편이 낫다.) Java Heap의 메모리 해제는 오로지 Garbage Collection을 통해서만 수행한다.

 

지금 다루어볼 Heap의 구조는 Hotspot JVM의 Heap 구조를 다루어 볼 것이다. 하지만 Hotspot JVM Heap 구조는 Java 8 버전 전후로 구조가 바뀌었다. 해당 내용은 기술 면접에서도 나올 만한 내용이다. 왜냐? 지금은 대부분 Java 1.8 이상을 사용하고 있기 때문이다. 아래 그림을 참조해보자. 

 

Java 1.7 이전

 

Java 1.8 이후

 

달라진 부분이 Perm 영역이다. Metaspace 영역으로 대체된 것이 보인다. 이것은 뒤에서 설명할 것이다.

 

Hotspot JVM은 크게 Young Generation과 Old Generation으로 나누어져 있다. Young Generation은 Eden 영역과 Suvivor 영역으로 구성되는데 Eden 영역은  Object가 Heap에 최초로 할당되는 장소이며 Eden 영역이 꽉 차게 되면 Object의 참조 여부를 따져 만약 참조가 되어 있는 Live Object이면 Suvivor 영역으로 넘기고, 참조가 끊어진 Garbage Object 이면 그냥 남겨 놓는다. 모든 Live Object가 Survivor 영역으로 넘어가면 Eden 영역을 모두 청소한다.

 

Suvivor 영역은 말 그대로 Eden 영역에서 살아남은 Object들이 잠시 머무르는 곳이다. 이 Suvivor 영역은 두 개로 구성되는데 Live Object를 대피시킬 때는 하나의 Survivor 영역만 사용하게 된다. 이러한 전반의 과정을 Minor GC라고 한다.

 

Young Generation에서 Live Object로 오래 살아남아 성숙된 Object는 Old Generation으로 이동하게 된다. 여기서 성숙된 Object란 의미는 애플리케이션에서 특정 회수 이상 참조되어 기준 Age를 초과한 Object를 말한다. 즉, Old Generation 영역은 새로 Heap에 할당되는 Object가 들어오는 것이 아니라, 비교적 오랫동안 참조되어 이용되고 있고 앞으로도 계속 사용될 확률이 높은 Object들이 저장되는 영역이다. 이러한 Promotion 과정 중 Old Generation의 메모리도 충분하지 않으면 해당 영역에서도 GC가 발생하는데 이를 Full GC(Major GC)라고 한다.

 

Java 1.7 기준의 Perm 영역은 보통 Class의 Meta 정보나 Method의 Meta 정보, Static 변수와 상수 정보들이 저장되는 공간이다. 이 영역은 Java 1.8부터는 Native 영역으로 이동하여 Metaspace 영역으로 변경되었다.(다만, 기존 Perm 영역에 존재하던 Static Object는 Heap 영역으로 옮겨져 최대한 GC 대상이 될 수 있도록 하였다.)

 

조금 더 1.7과 1.8 메모리 구조의 변경에 대해 설명하자면, Java 1.8에서 JVM 메모리의 구조적인 개선 사항으로 Perm 영역이 Metaspace 영역으로 전환되고 기존 Perm 영역은 사라지게 되었다. Metaspace 영역은 Heap이 아닌 Native 메모리 영역으로 취급하게 된다. (이 말은 기존의 Perm은 Heap 안에 존재하는 것으로 알 수 있다.) Native 메모리는 Heap과 달리 OS레벨에서 관리 하는 영역이다.

 

Perm과 Metaspace를 표로 구분해 보았다.

구분 상세 구분 Perm Metaspace
저장 정보 클래스의 메타 정보 저장 저장
메소드의 메타 정보 저장 저장
Static 변수, 상수 저장 Heap 영역으로 이동
관리 포인트 메모리 관리(튜닝)

Heap 영역 튜닝

Perm 영역도 별도로 튜닝해야함

Heap 영역 튜닝

Native 영역 동적 조정

(별도 옵션으로 조절 가능)

GC 측면 GC 수행 대상 Full GC 수행 대상 Full GC 수행 대상
메모리 측면 메모리 크기(옵션)

-XX:PermSize

-XX:MaxPermSize

-XX:MetaspaceSize

-XX:MaxMetaspaceSize

 

Java 1.8로 넘어오면서 주의해야 할 점이 있다. 아래 명령어 결과를 보자.

Metaspace가 잡고 있는 Max Size가 약 16Exabyte이다. 이 수치는 64bit 프로세서가 취급할 수 있는 메모리 상한치라 할 수 있다. Metaspace 영역은 Native 메모리로 다루기 때문에 기본적으로 JVM에 의해 크기가 강제되지 않고 프로세스가 이용할 수 있는 메모리 자원을 최대한 활용할 수 있다고 본다. 만약 Classloader에 메모리 누수가 의심되는 경우 -XX:MaxMetaspaceSize를 지정할 필요성이 있다. 왜냐하면  최대 값을 정해놓지 않는다면 Metaspace 영역의 메모리가 계속해서 커질 것이고 결국은 서버가 뻑나는 경우가 생길 수 있다.

 

오늘은 여기까지 간단히 JVM 구조에 대해 다루어보았다. 다음 포스팅은 Heap에서 이루어지는 GC에 대한 포스팅을 할 예정이다. 자바 성능에 아주 중요한 요인중 하나인 GC는 튜닝 관점에서 아주 중요한 관리 포인트이기도 하다.

posted by 여성게
: