// 에드센스

정말 오오오오오랜만에 글을쓴다

다시 잘 써보자고


가비지란?

  • JVM의 힙영역에 할당됐던 메모리중 필요없게 된 메모리를 가비지라고 한다
  • C언어의 경우 free() 함수를 통해 직접 메모리 해제
  • 자바는 gc가 해준다
Person person = new Person();
person.setName("lsh");
person = null;

// 가비지 발생
person = new Person();
person.setName("ksh");
 

가비지 컬렉션 장점

  • 개발자가 완벽하게 신경쓰지 않아도 된다

가비지 컬렉션 단점

  • 언제 메모리가 해제될지 개발자가 알 수 없다
  • GC 동작중에는 STW, 오버헤드 발생

 

 

GC란

  • 힙 영역에서 유효하지 않은(사용하지 않는) 메모리를 자동으로 수거하는 기능
  • 힙 영역은 다음 두가지가 전제가 된다
    • 대부분의 객체는 금방 unreachable 상태가 된다
    • 오래된 객체 -> 새로운 객체로의 참조는 아주 적다
  • 따라서 힙 영역은 객체의 생존 기간에 따라 Young 영역과 Old 영역으로 나눔

young영역

  1. young은 eden과 survivor1, 2로 구성
    1. eden
      1. eden은 new를 통해 새로 생성된 객체가 위치함
      2. 정기적인 gc 수행 후 살아남은 객체들은 survivor1,2로 넘김
    2. survivor
      1. 최소 1번 이상의 gc에서 살아남은 객체가 위치한 영역
      2. survivor1이나 survivor2중 하나는 꼭 비어있어야 한다
  2. 이곳에서의 gc 동작을 Minor gc라고함

 

old영역

  • young영역에서 reachable 상태를 유지하여 살아남은 객체가 복사되어 이곳에 위치하게된다
  • 이곳에서의 gc를 메이저gc 혹은 full gc라고함

  • card table
    • 예외적으로 old영역에 있는 객체가 Young영역에 있는 객체를 참조하는 경우도 있다
    • 이럴때를 대비하여 512바이트의 덩어리(청크)로 되어있는 카드테이블이라는 것이 있다
    • 마이너 gc가 발생할때 old영역에서 참조하는 young으로 참조하는 객체가 있는지 old영역을 확인안해도 되기에 효율적

 

왜 굳이 영역을 나누는가?

  • heap 영역을 나누는 이유는 heap 전체를 탐색하여 메모리를 해제하는 full gc로 인한 성능상의 이슈를 최소화 시키기 위함
    • weak generational hypothesis의 장점을 극대화 시키기 위함
    • 주로 old 영역의 객체는 크기가 큰 것들이 대부분이고 gc 소요시간이 Minor gc보다 오래걸린다
      • Minor gc와 Major gc의 비율간의 트레이드 오프
  • survivor 영역을 두개로 나누는 이유는 메모리 단편화 문제를 해결하기 위함

내부단편화

 

외부단편화

 

 

 

GC 동작 방식

  • young 영역과 old 영역의 세부적인 동작방식은 다르나 다음 두가지는 공통이다
    • Stop the world
      • gc 실행을 위해 jvm이 애플리케이션의 실행을 멈추는 작업
      • gc를 실행하는 쓰레드를 제외한 모든 스레드가 중단됨
      • gc튜닝은 대부분 이 시간을 줄이는것
    • Mark and sweep
      • 마크 -> 사용되는 메모리와 그렇지 않은 메모리를 식별하는 작업
      • 스윕 -> 마크단계에서 사용되지 않는것으로 판별된 메모리를 해제하는 작업
      • 컴팩션 -> 파편화된 메모리 영역을 앞에서부터 채워나가는 작업

 

Minor GC 동작 방식

사진출처: https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EA%B0%80%EB%B9%84%EC%A7%80-%EC%BB%AC%EB%A0%89%EC%85%98GC-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%F0%9F%92%AF-%EC%B4%9D%EC%A0%95%EB%A6%AC

 

☕ 가비지 컬렉션 동작 원리 & GC 종류 💯 총정리

Garbage Collection(GC) 이란? 가비지 컬렉션(Garbage Collection, 이하 GC)은 자바의 메모리 관리 방법 중의 하나로 JVM(자바 가상 머신)의 Heap 영역에서 동적으로 할당했던 메모리 중 필요 없게 된 메모리 객

inpa.tistory.com

 

 

age란?

  • Survivor 영역에서 객체가 gc로부터 살아남은 횟수
  • 임계점에 다다르면 Promotion되어 old영역으로 이동한다
  • JVM중 일반적인 HotSpot JVM의 경우 age 임계값이 31이다
 

 

 

 

Major GC 동작 방식

  • young에서 age가 차서 넘어온 객체들
  • old 영역의 메모리가 부족해지면 major gc 수행
  • old 영역에서 한번에 삭제함
  • old 영역은 young 영역보다 상대적으로 큰 공간을 가지고있기에 gc 수행시간 길다 → STW
  • STW를 줄이기위해 여러 가비지 컬렉션 알고리즘이 존재

Minor Major GC 비교

  • 만약 gc가 동작해도 모든 객체가 reachable해서 삭제될 객체가 없다면? → OOM 발생

 

 

 

GC 알고리즘

Serial GC

  1. 1 core cpu일때 사용하기위함
  2. Gc를 처리하는 스레드가 1개라서 STW시간이 제일 길다
  3. 실무에서 사용하는 케이스가 거의 없다고한다

 

Parallel GC

  • 자바8의 default gc
  • Minor gc를 멀티스레드로 수행함 (Major gc는 여전히 싱글스레드)
  • Serial gc에 비해선 STW 감소
  • 스레드는 기본적으로 cpu 개수만큼 할당됨

 

Parallel old GC

  • Parallel gc를 개선함
  • old영역에서도 멀티스레드로 메이저 gc수행
  • Mark - summary - compact 방식

 

Cms GC

  • 어플리케이션의 쓰레드와 GC 쓰레드가 동시에 실행되어 stop-the-world 시간을 최대한 줄이기 위해 고안된 GC
  • 단, GC 과정이 매우 복잡해짐.
  • GC 대상을 파악하는 과정이 복잡한 여러단계로 수행되기 때문에 다른 GC 대비 CPU 사용량이 높다
  • 메모리 파편화 문제
  • CMS GC는 Java9 버전부터 deprecated 되었고 결국 Java14에서는 사용이 중지

 

G1GC

  • Java 9 이상부터 default GC
  • 기존의 GC 알고리즘에서는 Heap 영역을 물리적으로 고정된 Young / Old 영역으로 나누어 사용하였지만, G1 gc는 아예 이러한 개념을 뒤엎는 Region이라는 개념을 새로 도입하여 사용.
  • 전체 Heap 영역을 Region이라는 영역으로 체스같이 분할하여 상황에 따라 Eden, Survivor, Old 등 역할을 고정이 아닌 동적으로 부여
  • Garbage로 가득찬 영역을 빠르게 회수하여 빈 공간을 확보하므로, 결국 GC 빈도가 줄어드는 효과를 얻게 되는 원리
    • 이전의 GC들처럼 일일히 메모리를 탐색해 객체들을 제거하지 않는다
    • 대신 메모리가 많이 차있는 영역(region)을 인식하는 기능을 통해 메모리가 많이 차있는 영역을 우선적으로 GC 한다. → 영역(region)을 나눠 탐색하고 영역(region)별로 GC가 일어난다.
    • 또한 이전의 GC 들은 Young Generation에 있는 객체들이 GC가 돌때마다 살아남으면 Eden → Survivor0 → Survivor1으로 순차적으로 이동했지만, G1 GC에서는 순차적으로 이동하지는 않는다. 
    • 대신 G1 GC는 더욱 효율적이라고 생각하는 위치로 객체를 Reallocate(재할당) 시킨다. 
    • 예를 들어 Survivor1 영역에 있는 객체가 Eden 영역으로 할당하는 것이 더 효율적이라고 판단될 경우 Eden 영역으로 이동시킨다.

 

ZGC

  1. Java 15에 release됨
  2. 대량의 메모리(8MB ~ 16TB)를 low-latency로 잘 처리하기 위해 디자인 된 GC
  3. G1의 Region 처럼,  ZGC는 ZPage라는 영역을 사용하며, G1의 Region은 크기가 고정인데 비해, ZPage는 2mb 배수로 동적으로 운영됨. (큰 객체가 들어오면 2^ 로 영역을 구성해서 처리)
  4. ZGC가 내세우는 최대 장점 중 하나는 힙 크기가 증가하더도 STW의 시간이 절대 10ms를 넘지 않는다는 것

참고: GC 성능 비교

 

 

G1GC

  • G1은 Garbage First 라는 뜻
  • 조기 승격(Promotion)에 덜 취약하다. → old로 너무 빠르게 이동되는 문제 → STW
  • 대용량 heap에서 확장성(특히 중단시간)이 우수하다.
  • full STW GC를 없애거나 확 줄일 수 있다.
  • Java 9 부터 default GC
  • RSet(Remembered Set)을 통해 어떤 객체가 어떤 리전에 저장되어있는지 추적 가능 → 전체 힙을 뒤질필요가 없어짐

 

G1GC 동작 과정

  1. Initial Mark - SWT : Old 영역에서 존재하는 객체들이 참조하는 Survivor 영역을 찾는다. SWT가 발생한다.
  2. Root Region Scanning : Initial Mark 단계에서 식별한 Survivor 영역에서 Old 영역을 가리키는 레퍼런스를 식별한다.
  3. Concurrent Mark : 힙 전체에 걸쳐 접근 가능한 살아있는 객체를 찾는다.
  4. Remark - STW : Concurrent Mark 단계를 검증하고, 최종적으로 살아남을 객체들을 식별한다. 이 단계에서는 SATB(Snapshot-At-The-Beginning) 알고리즘이 사용된다. STW가 발생한다.
  5. Cleanup - STW : 어플리케이션을 멈추고(STW) 살아있는 객체가 가장 적은 리전에 대한 미사용 객체를 제거한다. 이후 STW를 끝내고, 앞서 GC 과정에서 완전히 비워진 리전을 FreeList에 추가하여 재사용할 수 있게 한다.
  6. Copy : GC 대상 리전이었지만 Cleanup 과정에서 완전히 비워지지 않은 리전의 살아남은 객체들을 새로운 리전에 복사하여 Compaction 작업을 수행한다.

 

gc튜닝

  • 언제 튜닝해야하는가? → 성능저하의 원인이 명백히 gc때문일때
  • 튜닝의 핵심은
    • old영역으로 넘어가는 객체 최소화하기
    • Major gc 시간 짧게 유지하기

즉, Major gc를 적게발생시키고, 발생했다면 빠르게 끝내야함

 

GC튜닝 방법

  • 힙 크기 설정
    • 힙이 크면 gc수행시간이 늘어남, 힙이 작으면 gc가 더 자주 수행됨 -> 잘 조절해야함
  • Young영역과 Old영역 크기 비율 + Eden과 Survivor의 크기 비율
    • old영역이 커지면 → Major gc가 자주발생하지않음 하지만 발생하면 오래걸림

 

 

 

G1GC 튜닝포인트

G1GC가 제공하는 파라미터가 많기에 튜닝 포인트도 많다

  • Maximum GC Pause Time
    • -XX:MaxGCPauseMillis 설정을 통해 GC 실행 중에 최대 일시 중지 시간을 지정함으로써 높은 지연 시간을 최소화 하거나 높은 처리량을 설정할 수 있다.

 

  • Young Gen 사이즈 세팅을 하지 말 것(-XX:MaxGcPauseMillis 설정을 할 경우)
    • -Xmn(new 영역)이나 -XX:NewRatio 설정을 피해야 한다.
    • G1GC 알고리즘은 일시 중지 목표 시간을 충족하기 위해 Young 영역을 임의로 수정하게 되는데 Young 영역을 명시적으로 설정할 경우 일시 중지 목표 설정이 정상적으로 작동하지 않는다.

 

  • 다른 GC 알고리즘에서 사용하던 JVM 인수 제거
    • 기본적으로 G1GC의 경우 다른 GC 알고리즘(Serial, Parallel, CMS)에서 사용하던 JVM 인수와 같이 사용할 경우 G1GC의 파라미터가 정상적으로 동작하지 않을 수 있다.

 

  • 문자열 중복 제거
    • -XX:+UseStringDeduplication 설정을 통해 문자열 중복을 제거 한다.
    • JDK 개발팀의 조사에 따르면 다음과 같은 자바 애플리케이션의 특징이 있다고 한다
      • 프로세스의 25%는 문자열임
      • 그 중 13.5%는 중복 문자열임
      • 평균 문자열의 길이는 45자임
    • 예를들어 멤버리스트 조회시 status가 “ACTIVE”라는 동일한 문자열이나 다 다른 메모리 공간 점유

 

그 외의 주요 파라미터

 

 

 

GC 튜닝보다 메모리 누수 예방이 우선

  • 메모리 누수 : 사용되지 않는 객체들이 힙영역에 남아있는것

Static 변수에 의한 누수

  • Static 변수는 클래스가 로드될때 생성되어 JVM이 종료될때까지 메서드 영역에 남아있는다 (사용 여부와 관계x)
  • Static 변수가 객체를 참조 중이라면 해당 객체는 GC의 대상이 되지 않는다
  • 더 이상 사용하지 않는다면 null을 할당하여 참조를 제거하자

 

무분별한 Autoboxing

public class Adder {
       public long addIncremental(long l)
       {
              Long sum = 0L;
              sum = sum + l;
              return sum;
       }
       public static void main(String[] args) {
              Adder adder = new Adder();
              for(long i ; i < 1000 ; i++) {
                adder.addIncremental(i);
              }
       }
}

 

 

컬렉션 클래스의 데이터를 해제하지 않는 경우

  • List, Map, Set 같은 컬렉션 클래스들에 객체가 담겨있는 경우 객체의 참조가 해제되지 않음
  • null 참조로 해제한다
      public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        Object result = elements[--size];
        elements[size] = null; // 다 쓴 객체 참조 해제
        return result;
    }
  • Weak reference를 사용한다
    • 특정 key 값이 더 이상 사용되지 않는다고 판단되면 해당 Key - Value 쌍을 삭제
  • 자바에서 참조 종류는 다음 4가지가 있다
    • 강한 참조(Strong Reference)
      • 강한 참조는 Java의 기본 참조 유형으로 new 할당 후 새로운 객체를 만들어 해당객체를 참조하는 방식
      • 강함 참조를 통해 참조되고 있는 객체는 참조가 해제되지 않는 이상 가비지 컬렉션의 대상에서 제외된다.
Object obj = new Object();
// 만약 GC 를 원한다면 명시적으로 null 표시를 해줘야 한다.
obj = null;
  • 약한 참조(Weak Reference)
    • 약한 참조는 java의 lang 패키지의 WeakReference 클래스를 사용하여 생성한다.
    • 약한 참조는 GC가 발생하면 무조건 수거된다.
    • WeakReference가 사라지는 시점이 GC의 실행 주기와 일치한다.
Object obj = new Object();
WeakReference<Object> weakRef = new WeakReference<>(obj);
obj = null;

System.gc();

// 무조건 null 을 확인하게 된다.
System.out.println(weakRef.get());
  • Soft Reference
    • Soft 참조는 강한 참조와 약한 참조와는 다르게 GC에 의해 수거될 수도 있고, 수거되지 않을 수도 있다.
    • 메모리에 충분한 여유가 있다면 GC가 수행된다 하더라도 수거되지 않는다. 하지만 메모리가 부족하면 수거될 확률이 높다.
Object obj = new Object();
SoftReference<Object> softRef = new SoftReference<>(obj);
obj = null;

System.gc();

// GC 가 여유롭다면 해시코드를 확인할 수 있다.
System.out.println(softRef.get());
  • Phantom Reference?
    • 가장 약한 참조 유형입니다.
    • 객체 수거시에도 참조가 남아있는 참조 유형입니다.
    • 객체의 finalize() 메서드가 호출된 직후에 GC 에 의해 수거됩니다.
    • java.lang.ref PhantomReference class 로 만들 수 있습니다.
    • 생성자에는 넣고자 하는 클래스와 함께 ReferenceQueue 를 인자로 받습니다.
    • PhantomReference 는 객체가 참조되지 않습니다.
    • 객체의 finalize 메서드가 호출된 직후 Phantom Reference 가 ReferenceQueue 에 등록됩니다.
    • 이를 통해 객체의 finalize() 메서드가 호출되었음을 알 수 있습니다.
    • 일반적으로 Phantom Reference 는 Native 객체나 Direct Memory 와 같이 JVM 에서 관리되지 않는 자원들을 해제하기 위해 사용됩니다.

 

CustomKey 사용으로 인한 누수

  • Map을 사용할 때 custom key를 사용할 때는 equals()와 hashcode()를 값을 기반으로 구현해야한다
  • 아래의 경우 Key값이 같은 객체로 인식하지 못해서 계속 Map에 쌓이게 되면서 메모리를 점유하게 된다
public class CustomKey {
    private String name;
    
    public CustomKey(String name) {
        this.name=name;
    }
    
    public staticvoid main(String[] args) {
        Map<CustomKey,String> map = new HashMap<CustomKey,String>();
        map.put(new CustomKey("Shamik"), "Shamik Mitra");
   }
}

 

 

해제되지 않은 리소스로 인한 누수

  • close()나 try with resources 구문으로 리소스 반환해야함
public class Main {
    public static void main(String[] args) {
        try (FileWriter file = new FileWriter("data.txt")) { // 파일을 열고 모두 사용되면 자동으로 닫아준다
            file.write("Hello World");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}


1. 객체 정렬?

class Person {
    String name;
    int weight;
    int height;

    public Person(String name, int height, int weight) {
        this.name = name;
        this.weight = weight;
        this.height = height;
    }

    public void print() {
        System.out.println(name + " " + height + " " + weight);
    }

    public int getWeight() {
        return weight;
    }

    public int getHeight() {
        return height;
    }
}

여기 Person 클래스가 있다.

 

 

List<Person> list = new ArrayList<>();

Person 클래스로 이루어진 어레이리스트도 있다.

 

이 리스트를 정렬하고하자한다.

name을 기준으로 정렬을 해야할까 weight를 기준으로 정렬을 해야할까?

우리는 이 문제를 Comparable이나 Comparator를 통해서 해결할 수 있다.

 

 

 

 


1. Comparable

정렬하고자 하는 객체의 클래스에 Comparable 인터페이스를 구현하는 방법이다.

물론 그 클래스에 Comparable 인터페이스를 구현할 수 있다면 말이다.

// 정렬 대상 클레스에 인터페이스를 구현할 수 있다면 Comparable 사용 가능
class Person implements Comparable<Person> {
    String name;
    int weight;
    int height;

    public Person(String name, int height, int weight) {
        this.name = name;
        this.weight = weight;
        this.height = height;
    }

    public void print() {
        System.out.println(name + " " + height + " " + weight);
    }

    public int getWeight() {
        return weight;
    }

    public int getHeight() {
        return height;
    }

    @Override
    public int compareTo(Person p) {
        // 오름차순: this 객체 - 인자로 넘어온 객체
        // 내림차순: 인자로 넘어온 객체 - this 객체
        return this.height - p.height; // 오름차순 정렬
    }
}
Collections.sort(list);

Comparable 인터페이스의 compareTo 메소드를 구현하면 된다.

위 코드는 height를 기준으로 오름차순 정렬의 예시다.

 

 

 

만약 1순위로 height를 기준으로 오름차순 정렬하고

height가 같은 객체가 있다면

2순위로 weight를 기준으로 내림차순 정렬하고 싶다면?

 

class Person implements Comparable<Person> {
    String name;
    int weight;
    int height;

    public Person(String name, int height, int weight) {
        this.name = name;
        this.weight = weight;
        this.height = height;
    }

    public void print() {
        System.out.println(name + " " + height + " " + weight);
    }

    public int getWeight() {
        return weight;
    }

    public int getHeight() {
        return height;
    }

    @Override
    public int compareTo(Person p) {
        // this 객체 > 인자로 넘어온 객체 => return 1이라는것은
        // this 객체 - 인자로 넘어온 객체 > 0 => 오름차순
        if (this.height > p.height) return 1;
        else if (this.height == p.height) { // height가 같다면
            // this 객체 < 인자로 넘어온 객체 => return 1이라면
            // this 객체 - 인자로 넘어온 객체 < 0 => 내림차순
            if (this.weight < p.weight) return 1; // weight를 내림차순으로
        }
        return -1;
    }
}

이렇게 하면 된다.

 

 

 

 


2. Comparator

정렬하고자 하는 객체의 클래스에 Comparable 인터페이스를 구현할 수 없을때 사용하는 방법이다.

혹은 Comprable 인터페이스를 통해 이미 정렬기준이 정해져있지만 다른 기준으로 정렬하고 싶을때 사용하는 방법이다.

 

// 정렬 대상 클레스에 인터페이스를 구현할 수 없다면
// 혹은 Comparable을 통해 이미 정해져있는 정렬 기준과 다르게 정렬하고 싶다면
class Person {
    String name;
    int weight;
    int height;

    public Person(String name, int height, int weight) {
        this.name = name;
        this.weight = weight;
        this.height = height;
    }

    public void print() {
        System.out.println(name + " " + height + " " + weight);
    }

    public int getWeight() {
        return weight;
    }

    public int getHeight() {
        return height;
    }
}

Person 클래스는 동일하다.

 

 

 

우리는 Comparator라는 일종의 정렬기준을 정의한 객체를 만들어서 Collections.sort()의 인자로 넣어주어야 한다.

 

    public static <T> void sort(List<T> list, Comparator<? super T> c) {
        list.sort(c);
    }

 

Collections.sort 함수를 까보면 첫번째로 정렬 할 리스트와 두번째로 정렬 기준인 Comparator를 받는 것을 볼 수 있다.

 

 

 

Collections.sort(list, new Comparator<Person>() {
    @Override
    public int compare(Person o1, Person o2) {
    	// 오름차순
        return o1.height - o2.height;
    }
});

 

Comparator객체를 한번쓰고 버릴것 이니 익명클래스로 넣어주었다.

저 익명클래스의 객체는 Comparator 인터페이스를 구현받고있기에 compare 함수를 오버라이드하고있다.

 

 

 

정렬 기준이 2개 이상일 때도 Comparable과 동일하게 해주면 된다.

Collections.sort(list, new Comparator<Person>() {
    @Override
    public int compare(Person o1, Person o2) {
    	// height 기준 오름차순
        if (o1.height > o2.height) return 1;
        else if (o1.height == o2.height) {
        	// height가 같다면 weight 기준으로 내림차순
            if (o1.weight < o2.weight) return 1;
        }
        return -1;
    }
});

 

 

 

Comparator를 람다함수로 조금더 간단하게 표현할 수 있다.

Collections.sort(list, (a, b) -> a.getHeight() - b.getHeight());

 

 

 

stream을 쓰면 더더더 간단하게 표현할 수 있다.

list.sort(Comparator.comparing(Person::getHeight));

 

 

 

stream을 사용했을때 2개 이상의 기준을 적용하고 싶다면

list.sort(Comparator.comparing(Person::getHeight).thenComparing(Person::getWeight));

 

이렇게 thenComparing을 통해 이어주면 된다.

하나의 조건은 역순으로 하고싶다면 .reversed()를 붙혀주면 되는데 주의 할 것이

 

 

 

list.sort(Comparator.comparing(Person::getHeight).thenComparing(Person::getWeight).reversed());

 

이렇게 뒤에 reversed()를 바로 붙이면 height에 대한 정렬도 reversed()에 영향을 받아서 두가지 기준 다 내림차순 정렬이 돼 버린다.

 

 

 

두번째 기준만 내림차순으로 하고 싶다면

Comparator<Person> reverseCompare = Comparator.comparing(Person::getWeight).reversed();
list.sort(Comparator.comparing(Person::getHeight).thenComparing(reverseCompare));

 

이렇게 reverse용 Comparator를 따로 만들어서 넣어주어야 한다.

 

 

 

 

참고:

 

사실 자료구조 카테고리에 맞는 게시글이지만 아직 자료구조 카테고리가 없고 앞으로 딱히 만들 계획이 없기에, 그리고 구현을 자바로 했기에 자바 카테고리에 넣었다! 그냥 그런걸로 하자 ㅎㅎ

 

해시란?

해쉬브라운

  • 해시란 임의의 크기를 가진 데이터를 고정된 크기의 데이터로 변화시켜 저장하는것이다. 이 과정은 해시함수를 통해 진행되며 해시값 자체를 index로 사용하기에 평균 시간복잡도가 O(1)으로 매우 빠르다
  • 키(key) 1개와 값(value) 1개가 1:1로 연관되어 있는 자료구조이다. 따라서 키(key)를 이용하여 값(value)을 도출할 수 있다.
    이 그럼처럼 John Smith라는 이름과 전화번호가 매핑이 되어있고 전화번호를 찾기위해선 John Smith라는 이름을 해시함수를 통해 변환한 해시코드를 통해 찾을 수 있다. 

 

 

해시함수와 충돌

key를 해시함수를 통해서 해시코드로 변환시키고 이 해시코드를 인덱스로 사용하여 value를 저장하는데, 이때 충돌(Collision)이 발생할 수 있다. 다음의 예시를 보자

John Smith와 Sandra Dee라는 key가 해시함수를 통해 해시코드로 변환되었는데 우연히 같은 코드로 변환된 것이다. 

 

즉, 무한한 값(KEY)을 유한한 값(HashCode)으로 표현하면서

서로 다른 두 개 이상의 유한한 값이 동일한 출력 값을 가지게 된다는 것이다.

 

key가 될 수 있는 경우는 무한하고 해시테이블은 유한하니 소위 비둘기집 원리라고 부르는 문제가 발생한다. 이런 문제로 인해 우리는 해시함수의 중요성을 느낄 수 있다. 최대한 겹치지 않고 다양한 값을 보장하는 해시 함수라면 이런 문제를 조금 개선할 수 있지만 그래도 근본적으로는 불가능하다. 따라서 우리는 다른 개선방법을 사용한다. 크게 두가지의 해결 방법이 있는데 Separate Chaining기법과 Open Addressing(개방 주소법)이 있다.

 

 

충돌 해결1. Separate Chaining(Chaining) 기법

John Smith가 들어가 있는데 그 공간에 또 Sandra Dee가 들어갈때 Collision이 발생한다. 이때 Sandra의 value를 기존 John의 뒤에 체인처럼 이어 붙혀준다. 152번지에 John과 Sandra의 정보가 함께 존재하도록 한것이다.

 

장점

  • 한정된 저장 공간을 효율적으로 사용할 수 있다.
  • 해시 함수에 대한 의존성이 상대적으로 적다.
  • 메모리 공간을 미리 잡아 놓을 필요가 없다.(그때그때 이어 붙이기 때문)

단점

  • 한 hash에만 자료가 계속 들어간다면(쏠린다면) 검색 효율이 떨어진다(.최악의 경우 O(n))
  • 외부 저장공간을 사용한다.

 

 

충돌 해결2. Open Addressing(개방주소법)

개방주소법은 데이터의 해시(hash)가 변경되지 않았던 chaining과는 달리 비어있는 해시(hash)를 찾아 데이터를 저장하는 기법이다. 따라서 개방주소법에서의 해시테이블은 1개의 해시와 1개의 값(value)가 매칭되어 있는 형태로 유지된다.

 

 

장점

  • 추가 저장공간이 필요없다

단점

  • 해시 함수의 성능에 전체 해시테이블의 성능이 좌지우지 된다.
  • 데이터의 길이가 늘어나면 그에 해당하는 저장소를 마련해 두어야한다.

 

 

 

Chaining 기법을 사용한 해시테이블 구현

HashTable 클래스

import java.util.LinkedList;

public class HashTable {
	class Node{
		String key;
		String value;
		public Node(String key, String value) {
			this.key = key;
			this.value = value;
		}
		
		String getValue() {
			return value;
		}
		
		void setValue(String value) {
			this.value = value;
		}
	}
	
	//각 배열 칸에 링크드리스트를 넣음으로서 collision이 발생할 시 뒤에 이어나간다.
	LinkedList<Node>[] data;
	
	//해시테이블을 생성하는 순간 생성자를 통해서 배열 크기 초기화
	HashTable(int size){
		this.data = new LinkedList[size];
	}
	
	//키를 해쉬코드로 변환하는 메소드
	int getHashCode(String key) {
		int hashcode = 0;
		for(char c : key.toCharArray()) {
			hashcode += c;
		}
		return hashcode;
	}
	
	//해쉬코드를 배열의 인덱스로 변환하는 메소드
	int convertHashCodeToIndex(int hashcode) {
		return hashcode % data.length;
	}
	
	//배열의 인덱스에 노드가 여러개 있다면 key를 통해 알맞은 value를 찾는 메소드
	Node searchKey(LinkedList<Node> list , String key) {
		//리스트에 아무것도 없으면 null 반환
		if(list == null) {
			 return null;
		}
		
		//리스트에 있는 노드중에 찾는 key를 가진 노드가 있다면 반환
		for(Node node : list) {
			if(node.key.equals(key)) {
				return node;
			}
		}
		
		//리스트에 노드가 없다면 null 반환
		return null;
	}
	
	//key-value를 저장하는 메소드
	void put(String key, String value) {
		int hashcode = getHashCode(key);
		int index = convertHashCodeToIndex(hashcode);
		
		//배열의 해당 인덱스에 들어가있던 리스트 가져온다
		LinkedList<Node> list = data[index];
		
		//배열의 해당 인덱스 번지에 아직 리스트가 없다면
		if(list == null) {
			//리스트 만들고 해당 인덱스에 넣는다
			list = new LinkedList<Node>();
			data[index] = list;
		}
		
		//가져온 리스트에 지금 넣고자하는 key가 먼저 들어가있는지 확인
		Node node = searchKey(list, key);
		
		//노드가 없다면 처음 들어가는 key라는 의미
		if(node == null) {
			list.addLast(new Node(key, value));
		}
		else {
			//이미 해당 key로 들어가있는 노드가 있다면 지금 넣는 key로 덮어쓰기
			node.value = value;
		}
	}
	
	//key를 통해 value 가져오는 메소드
	String get(String key) {
		int hashcode = getHashCode(key);
		int index = convertHashCodeToIndex(hashcode);
		LinkedList<Node> list = data[index];
		
		//해당 인덱스에 있는 list에서 key를 통해 value를 찾는다
		Node node = searchKey(list, key);
		
		//해당 key값의 node가 없으면 Not Found반환, 있으면 value 반환
		return node == null ? "Not Found" : node.value;
	}
}

 

HashTest 클래스

public class HashTest {

	public static void main(String[] args) {
		
		//크기 3의 해쉬테이블 생성
		HashTable ht = new HashTable(3);
		
		ht.put("Lee", "lee is pretty");
		ht.put("Kim", "kim is smart");
		ht.put("Hee", "hee is an angel");
		ht.put("Choi", "choi is cute");
		
		//존재하는 데이터 검색
		System.out.println(ht.get("Lee"));
		System.out.println(ht.get("Kim"));
		System.out.println(ht.get("Hee"));
		System.out.println(ht.get("Choi"));

		//존재하지 않는 데이터 검색
		System.out.println(ht.get("Kang"));
		
		//기존 데이터 덮어쓰기
		ht.put("Choi", "choi is sexy");
		System.out.println(ht.get("Choi"));
	}
}

 

 

데이터는 Node라는 클래스 형태로 저장된다. Node는 key와 value를 가지고 있고 Value의 getter와 setter가 있다.

class Node{
		String key;
		String value;
		public Node(String key, String value) {
			this.key = key;
			this.value = value;
		}
		
		String getValue() {
			return value;
		}
		
		void setValue(String value) {
			this.value = value;
		}
	}

 

 

해시테이블은 배열로 선언하였고 각 칸마다 LinkedList<Node>형으로 선언하여 chaining 기법을 통한 Collision 회피 기법을 선택하였다.

	//각 배열 칸에 링크드리스트를 넣음으로서 collision이 발생할 시 뒤에 이어나간다.
	LinkedList<Node>[] data;

 

 

해시함수는 key의 각 문자들을 유니코드로 반환하여 모두 더하는 방식으로 구성했다. 

인덱스는 해시코드를 해시테이블의 사이즈로 나눈 나머지 값을 사용했다.

	//키를 해쉬코드로 변환하는 메소드
	int getHashCode(String key) {
		int hashcode = 0;
		for(char c : key.toCharArray()) {
			hashcode += c;
		}
		return hashcode;
	}
	
	//해쉬코드를 배열의 인덱스로 변환하는 메소드
	int convertHashCodeToIndex(int hashcode) {
		return hashcode % data.length;
	}

 

 

 

조회를 희망하는 key를 받아서 value를 찾는 메소드이다. key를 받아서 해시함수로 변환 후 인덱스로 변환하여 해당 인덱스에 존재하는 list를 가져온다. 그 리스트에서 우리가 입력한 key를 가진 Node를 찾는 searchKey 메소드를 통해 목적 Node를 찾아낸다.

	//key를 통해 value 가져오는 메소드
	String get(String key) {
		int hashcode = getHashCode(key);
		int index = convertHashCodeToIndex(hashcode);
		LinkedList<Node> list = data[index];
		
		//해당 인덱스에 있는 list에서 key를 통해 value를 찾는다
		Node node = searchKey(list, key);
		
		//해당 key값의 node가 없으면 Not Found반환, 있으면 value 반환
		return node == null ? "Not Found" : node.value;
	}

 

 

searchKey 메소드에서는 우리가 입력한 key를 가진 Node가 존재하는지 확인한다.

	//배열의 인덱스에 노드가 여러개 있다면 key를 통해 알맞은 value를 찾는 메소드
	Node searchKey(LinkedList<Node> list , String key) {
		//리스트에 아무것도 없으면 null 반환
		if(list == null) {
			 return null;
		}
		
		//리스트에 있는 노드중에 찾는 key를 가진 노드가 있다면 반환
		for(Node node : list) {
			if(node.key.equals(key)) {
				return node;
			}
		}
		
		//리스트에 노드가 없다면 null 반환
		return null;
	}

 

 

해시테이블에 데이터를 넣는 메소드로 chaining 기법을 구현했다. 중복되는 key가 이미 존재할 경우 해당 key에대한 value를 덮어쓰는 것으로 구현했다.

	//key-value를 저장하는 메소드
	void put(String key, String value) {
		int hashcode = getHashCode(key);
		int index = convertHashCodeToIndex(hashcode);
		
		//배열의 해당 인덱스에 들어가있던 리스트 가져온다
		LinkedList<Node> list = data[index];
		
		//배열의 해당 인덱스 번지에 아직 리스트가 없다면
		if(list == null) {
			//리스트 만들고 해당 인덱스에 넣는다
			list = new LinkedList<Node>();
			data[index] = list;
		}
		
		//가져온 리스트에 지금 넣고자하는 key가 먼저 들어가있는지 확인
		Node node = searchKey(list, key);
		
		//노드가 없다면 처음 들어가는 key라는 의미
		if(node == null) {
			list.addLast(new Node(key, value));
		}
		else {
			//이미 해당 key로 들어가있는 노드가 있다면 지금 넣는 key로 덮어쓰기
			node.value = value;
		}
	}

 

 

 

참고:

 

정처기 벼락치기하느라 며칠 못올렸다.. 결과는 아직 모르겠다. 너무 간당간당 ㅠㅠ

암튼 이제 다시 한개씩 해보자 후. 오늘은 제네릭에 대한 개념 정리를 해보겠다.

 

제네릭이란?

"제네릭(Generic)은 클래스 내부에서 지정하는 것이 아닌

외부에서 사용자에 의해 지정되는 것을 의미"

 

한 줄 요약하면 위와 같다. 말하자면 타입을 매개변수로 넣어주는 그런 느낌.

class Person<T>{
    public T info;
}
 
public class GenericDemo {
 
    public static void main(String[] args) {
        Person<String> p1 = new Person<String>();
        Person<Integer> p2 = new Person<Integer>();
    }
 
}

Person 클래스를 생성할때 <여기>에 타입을 지정해주면 제네릭 변수 T를 통해서 info의 타입이 정해진다. T라는 문자 말고 다른 문자를 써도 되지만 암묵적인 룰이 있다.

타입 설명
<T> Type
<E> Element
<K> Key
<V> Value
<N> Number

 

그러면 이걸 왜 쓰는 것이고 쓰면 뭐가 좋은지 예제를 통해 탐구해보자.

 

 

 

제네릭을 쓰면 좋은점

타입안정성을 확보하고 중복을 줄일 수 있다.

먼저 다음의 중복이 있는 코드를 보자

class StudentInfo{
    public int grade;
    StudentInfo(int grade){ this.grade = grade; }
}
class StudentPerson{
    public StudentInfo info;
    StudentPerson(StudentInfo info){ this.info = info; }
}
class EmployeeInfo{
    public int rank;
    EmployeeInfo(int rank){ this.rank = rank; }
}
class EmployeePerson{
    public EmployeeInfo info;
    EmployeePerson(EmployeeInfo info){ this.info = info; }
}

public class WithoutGeneric {
	public static void main(String[] args) {
	StudentInfo si = new StudentInfo(2);
        StudentPerson sp = new StudentPerson(si);
        System.out.println(sp.info.grade); 			// 2
        EmployeeInfo ei = new EmployeeInfo(1);
        EmployeePerson ep = new EmployeePerson(ei);
        System.out.println(ep.info.rank); 			// 1
	}
}

타입안정성이 확보된 코드이지만, StudentPerson 클래스와 EmployeePerson에서 중복이 발생했다. 같은 목적의 클래스이지만 타입이 다르기에 두번 쓴것인데 이를 개선하고자 이 두 클래스를 Person이라는 하나의 클래스로 통일해보자.

 

 

class StudentInfo{
    public int grade;
    StudentInfo(int grade){ this.grade = grade; }
}
class EmployeeInfo{
    public int rank;
    EmployeeInfo(int rank){ this.rank = rank; }
}
class Person{
    public Object info;
    Person(Object info){ this.info = info; }
}
public class GenericDemo {
    public static void main(String[] args) {
        Person p1 = new Person("부장");
        EmployeeInfo ei = (EmployeeInfo)p1.info;
        System.out.println(ei.rank);
    }
}

모든 타입을 받을 수 있는 Object형으로 info를 선언함으로 중복을 줄였다. 

그리고 EmployeeInfo ei = (EmployeeInfo)p1.info 으로 EmployeeInfo의 객체를 생성하려고 했다. 이때 컴파일에서 잡히지 않던 에러가 발생한다. 바로 EmployeeInfo의 멤버변수 rank는 int형인데 여기에 "부장"이라는 String을 넣으려고 한다는 에러다. 이번에는 중복을 줄였지만 타입안정성을 확보하지 못한 모습을 볼 수 있다.

 

이제 제네릭을 적용해서 중복과 타입 안정성을 모두 챙겨보자.

class StudentInfo{
    public int grade;
    StudentInfo(int grade){ this.grade = grade; }
}
class EmployeeInfo{
    public int rank;
    EmployeeInfo(int rank){ this.rank = rank; }
}
class Person<T>{
    public T info;
    Person(T info){ this.info = info; }
}

public class WithGeneric {

	public static void main(String[] args) {
	Person<EmployeeInfo> p1 = new Person<EmployeeInfo>(new EmployeeInfo(1));
        EmployeeInfo ei1 = p1.info;
        System.out.println(ei1.rank); // 성공
         
        Person<String> p2 = new Person<String>("부장");
        String ei2 = p2.info;
        System.out.println(ei2.rank); // 컴파일 실패
	}
}

이 경우에는 맨 마지막줄에서 빨간줄이 뜨면서 컴파일 에러가 발생한다. 즉 중요한것은

  • 런타임이 아닌 컴파일 단계에서 오류가 검출된다.
  • 중복의 제거와 타입 안정성을 동시에 추구할 수 있다.

 

 

제네릭의 특성

1. 복수의 제네릭도 가능하다

class Person<T, S>{
    public T info;
    public S id;
    Person(T info, S id){ 
        this.info = info; 
        this.id = id;
    }
}

 

2. 기본타입은 안되고 참조타입만 사용할 수 있다.

 

 

제네릭의 제한

제네릭으로 올 수 있는 데이터 타입을 특정 클래스의 자식으로 제한할 수 있다.

abstract class Info{
    public abstract int getLevel();
}
class EmployeeInfo extends Info{
    public int rank;
    EmployeeInfo(int rank){ this.rank = rank; }
    public int getLevel(){
        return this.rank;
    }
}
class Person<T extends Info>{
    public T info;
    Person(T info){ this.info = info; }
}
public class GenericDemo {
    public static void main(String[] args) {
        Person p1 = new Person(new EmployeeInfo(1));
        Person<String> p2 = new Person<String>("부장");
    }
}

class Person<T extends Info>를 보면 T타입은 Info클래스 자신이거나 이것을 상속받는 타입만 가능하다는 의미이다. 상속뿐 아니라 인터페이스의 implements도 가능하다. 반대로 부모타입만 가능하다는 의미의 super도 가능하다!

 

 

참고:

'Java' 카테고리의 다른 글

[Java] Comparable과 Comparator로 객체 정렬하기  (1) 2022.09.12
[Java] 해시/해시테이블이란?  (0) 2021.07.12
[Java] Garbage Collection  (4) 2021.07.01
[Java] Thread/MultiThread 4 - 동시성 문제  (0) 2021.06.29
[Java] Static 키워드  (0) 2021.06.28

프로그래밍을 하다보면 사용하지 않는 일명 "쓰레기"공간이 발생하여 프로그램의 성능을 저하시킨다. 자바에선 가비지 컬렉터가 이를 자동으로 탐지하여 해결해주는데 이 일을 해주는 가비지 컬렉터에 대해 알아보자. (이하 GC라고 하겠다.)

 

 

GC란

Person p1 = new Person("Kim");
Person p2 = new Person("Lee");

// p2가 가리키던 객체는 가비지가 된다.
p2 = p1;

 

 

Kim이라는 이름을 가진 p1 객체와 Lee라는 이름을 가진 p2객체가 있는데 p2라는 참조변수가 p1객체를 가리키게 한다면 원래 p2가 가리키던 Lee라는 객체는 더 이상 참조받을 수 없다. 즉 unreachable object가 되며 이를 가비지라고 한다. 

 

가비지 컬렉션이란

JVM의 힙영역에서 사용하지 않는 객체를 삭제하는 프로세스를 말한다.

 

 

GC는 Mark and sweep 알고리즘을 통해 동작한다.

  • mark는 reachable한 객체와 unreachable한 객체를 식별하는 과정
  • sweep은 식별한 unreachable객체를 제거하는 과정
  • compact과정도 추가되기도 한다. (메모리 단편화를 방지)

 

 

언제 동작하는가

GC도 결국엔 JVM에 올라가기 때문에 기본적으로 런타임에 동작한다.

가비지 컬렉션이 실행되기에는 몇 가지 조건이 있는데, 다음 조건 중 하나라도 충족되면 JVM은 GC를 실행한다.

 

  • OS로부터 할당 받은 시스템의 메모리가 부족한 경우
  • 관리하고 있는 힙에서 사용되는 메모리가 허용된 임계값을 초과하는 경우
  • 프로그래머가 직접 GC를 실행하는 경우(Java에서는 System.gc()라는 메소드가 있지만 가급적 안 쓰는 것이 좋다.)

 

 

JVM의 Heap 메모리 구조

이렇게 생겼다. 여기서 우리는 오른쪽의 Permanent영역을 제외한 부분만 살펴보자. 참고로 Young 영역에서 발생하는 GC를 Minor GC, Old영역의 GC는 Major GC라고 한다. Minor GC와 Major GC를 따로 만든 이유는 대부분의 객체는 금방 가비지가 된다는 가설을 전제로 하고 GC를 설계했기 때문이다.

Minor GC의 범위에서 사용되는 객체들(파란영역)이 훨씬 많은것을 알 수 있다.

 

Young Generation

GC를 이해하기 위해서 객체가 제일 먼저 생성되는 Young 영역부터 알아보자. Young 영역은 3개의 영역으로 나뉜다.

  • Eden 영역
  • Survivor 영역(2개)

Survivor 영역이 2개이기 때문에 총 3개의 영역으로 나뉘는 것이다. 각 영역의 처리 절차를 순서에 따라서 기술하면 다음과 같다.

  • 새로 생성한 대부분의 객체는 Eden 영역에 위치한다.
  • Eden영역이 꽉 차면 GC(Minor GC)가 발생한다.
  • Eden 영역에서 GC(Minor GC)가 한 번 발생한 후 살아남은 객체는 Survivor 영역 중 하나로 이동된다.
  • 이때 Survivor영역은 둘 중 한쪽만 사용돼야 한다.
  • 그렇기에 Minor GC가 발생할때 마다 두 군데의 Survivor 영역을 이동하며 저장된다.
  • GC가 발생할때마다 살아남은 객체들은 Age가 증가한다.
  • 일정 Age에 도달한 객체들은 Old 영역으로 이동하게 된다.

이 절차를 확인해 보면 알겠지만 Survivor 영역 중 하나는 반드시 비어 있는 상태로 남아 있어야 한다. 만약 두 Survivor 영역에 모두 데이터가 존재하거나, 두 영역 모두 사용량이 0이라면 여러분의 시스템은 정상적인 상황이 아니라고 생각하면 된다.

 

 

Old Generation

Young 영역에서 오랫동안 살아남은 객체들이 넘어오는 곳이다. 이곳 역시 꽉차면 Major GC의 과정이 수행된다. 주로 5가지의 GC방식이 존재한다.

  • Serial GC
  • Parallel GC
  • Parallel Old GC(Parallel Compacting GC)
  • Concurrent Mark & Sweep GC
  • G1(Garbage First) GC

 

*stop-the-world란 GC를 실행하기 위해 JVM이 애플리케이션 실행을 멈추는것

 

Serial GC

  • GC를 처리하는 쓰레드가 1개(싱글 쓰레드)
  • 다른 GC에 비해 stop-the-world 시간이 길다
  • Mark-Compact 알고리즘 사용

 

Parallel GC

  • Java8의 default GC
  • Young 영역의 GC를 멀티쓰레드로 수행
  • 그렇기에 Serial GC에 비해 stop-the-world 시간이 짧다

 

Parallel Old GC

  • Parallel GC를 개선
  • Old영역에서도 멀티쓰레드로 GC 수행
  • Mark-Summary-Compact 알고리즘 사용

 

CMS GC

  • stop-the-world 시간을 줄이기 위해 고안됨
  • compact과정이 없음

 

G1 GC

  • CMS GC를 개선
  • Java9의 default GC
  • Heap영역을 일정한 크기의 Region으로 나눔
  • 전체 Heap이 아닌 Region단위로 탐색
  • Compact 진행

 

 

 

 

참고:

더보기

'Java' 카테고리의 다른 글

[Java] 해시/해시테이블이란?  (0) 2021.07.12
[Java] 제네릭(Generic)  (0) 2021.07.11
[Java] Thread/MultiThread 4 - 동시성 문제  (0) 2021.06.29
[Java] Static 키워드  (0) 2021.06.28
[Java] JVM 구조와 동작원리  (0) 2021.06.27

스레드는 작업의 한 단위이다. 프로세스는 독자적인 메모리를 할당받아서 서로 다른 프로세스끼리는 일반적으로 서로의 메모리 영역을 침범하지 못한다. 하지만 프로세스 내부에 있는 여러 스레드들은 서로 같은 프로세스 내부에 존재하고 있기 때문에 같은 자원을 공유하여 사용할 수 있다. 같은 자원을 공유할 수 있기 때문에 동시에 여러 가지 일을 같은 자원을 두고 수행할 수 있고, 이는 곧 병렬성의 향상으로 이어진다.

 

잠깐 동시성과 병렬성을 짚고 넘어가자

 

 

동시성 VS 병렬성

동시성 병렬성
동시에 실행되는 것 같이 보이는 것 실제로 동시에 여러 작업이 처리되는 것
싱글 코어에서 멀티 쓰레드(Multi thread)를 동작 시키는 방식 멀티 코어에서 멀티 쓰레드(Multi thread)를 동작시키는 방식
한번에 많은 것을 처리 한번에 많은 일을 처리
논리적인 개념 물리적인 개념

 

첫번째 그림은 그냥 순차적으로 실행되는 모습(싱글코어)

 

두번째 그림은 동시성으로 실행되는 모습. 실제로 작업의 흐름은 한가닥이지만 여러 작업을 번갈아가며 조금씩 수행하기에 마치 동시에 진행되는 것 처럼 보인다. 작업 전환시마다 컨텍스트 스위칭이라고 비용이 발생한다.(싱글코어)

 

세번째 그림은 병렬성 작업으로 실제로 동시에 따로 작업이 진행되는 것이다.(멀티코어)

 

 

 

쓰레드 안정이 깨지는 상황

멀티 스레드 환경에서 스레드 안전(Thread-safe)한 프로그램을 제작하기 위해서는 어떤 경우에 스레드 안전하지 않은 동작이 생기는지 먼저 만들어볼 필요가 있다. 정말 간단한 예제로, 조회수 계산 로직이 있다. 특정 글을 조회하는 순산 원래 조회수에 1을 더한 값을 저장할 것이고, 여러 사용자가 동시에 접근할 것이므로 멀티 스레드 환경에서 동작한다고 가정해 보겠다.

 

public class CountingTest {
    public static void main(String[] args) {
        Count count = new Count();
        for (int i = 0; i < 100; i++) {
            new Thread(){
                public void run(){
                    for (int j = 0; j < 100; j++) {
                        System.out.println(count.view());
                    }
                }
            }.start();
        }
    }
}
class Count {
    private int count;
    public int view() {return count++;}
    public int getCount() {return count;}
}

해당 코드를 실행시켰을 때, 100명의 사용자가 100번 조회했으므로 100 * 100, 즉 10000번의 조회수가 나올것이라 예상 할 수 있다.

하지만 실제 결과값을 보았을 때는 10000번이 아닌 그보다 더 적은 조회수가 나온다. 그 이유는 조회수를 증가시키는 로직이 두 번의 동작으로 이루어지는데 동시에 여러 스레드가 접근하여 첫 번째 동작할 때의 자원과 두 번째 동작할 때의 자원 상태가 변하기 때문이다.

 

count++는

1. count변수 값을 조회한다.

2. count변수에 조회한 값에 1을 더한 값을 저장한다.

를 수행한다. 이때 발생하는 문제가 여러 쓰레드에서 count 변수를 동시에 조회하면 발생한다.

 

 

그림처럼 쓰레드1과 2에서 동시에 count변수의 값을 조회하면 둘 다 100이라는 값에 1을 더한 값을 count변수에 다시 저장하기에 102가 나와야하지만 실제론 101이 되는 것이다.

 

 

동시성을 제어하는 방법

1. 암시적 Lock

하나의 쓰레드가 접근했을때 다른 쓰레드는 접근하지 못하도록 Lock을 거는것이다. 동시성 문제를 해결할 수 있지만 한번에 하나의 쓰레드만 접근이 가능하므로 병렬성은 매우 떨어진다. 메서드에 synchronized 키워드를 붙이면 암시적 락을 적용할 수 있다.

 

메서드 Lock

class Count {
    private int count;
    public synchronized int view() {return count++;}
}

 

변수 Lock

class Count {
    private Integer count = 0;
    public int view() {
        synchronized (this.count) {
            return count++;
        }
    }
}

 

 

2. 명시적 Lock

synchronized 키워드 없이 명시적으로 ReentrantLock을 사용하는 Lock을 명시적 Lock이라고한다. 해당 Lock의 범위를 메서드 내부에서 한정하기 어렵거나, 동시에 여러 Lock을 사용하고 싶을 때 사용한다. 

 

명시적 Lock을 사용한 예제

public class CountingTest {
    public static void main(String[] args) {
        Count count = new Count();
        for (int i = 0; i < 100; i++) {
            new Thread(){
                public void run(){
                    for (int j = 0; j < 1000; j++) {
                        count.getLock().lock();
                        System.out.println(count.view());
                        count.getLock().unlock();
                    }
                }
            }.start();
        }
    }
}
class Count {
    private int count = 0;
    private Lock lock = new ReentrantLock();
    public int view() {
            return count++;
    }
    public Lock getLock(){
        return lock;
    };
}

 

 

3. 자원의 가시성을 책임지는 volatile

 

여러 쓰레드가 하나의 자원에 동시에 읽기/쓰기를 진행할때 항상 메모리에 접근하지 않는다. 성능 향상을 위해 CPU 캐시를 참조하여 값을 조회하는데 이 값과 메인 메모리의 값이 항상 일치하는지 보장할 수 없다. 즉, 변수를 조회하여 값을 읽었는데 실제 값과 다를 수 있다는 말이다. 실제 자원의 값(메인 메모리 값)을 볼 수 있는 개념을 자원의 가시성이라고 부르는에 이 자원의 가시성을 확보하지 못한것이다.

 

 

"멀티쓰레드 환경에서 쓰레드가 변수를 읽어올 때,

CPU 캐시에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생"

 

 

public class SharedObject {
    public int counter = 0;
}

Thread-1은 counter값을 증가시키고 있지만 CPU Cache에만 반영되어있고 실제로 Main Memory에는 반영이 되지 않는 상태. 그렇기 때문에 Thread-2는 count값을 계속 읽어오지만 0을 가져오는 문제가 발생. 

 

 

volatile은 이러한 CPU 캐시 사용을 막는다. 해당 변수에 volatile 키워드를 붙여주면 해당 변수는 캐시에 저장되는 대상에서 제외된다. 매 번 메모리에 접근해서 실제 값을 읽어오도록 설정해서 캐시 사용으로 인한 데이터 불일치를 막는다. 실제 메모리에 저장된 값을 조회하고 이를 통해 자원의 가시성을 확보할 수 있다.

 

public class SharedObject {
    public volatile int counter = 0;
}

 

volatile은 자원의 가시성을 확보해주지만 동시성 이슈를 해결하기에는 그리 충분하지 않다. 공유 자원에 read&write를 할 때는 동기화를 통해 해당 연산이 원자성을 이루도록 설정해주어야 동시성 이슈를 해결할 수 있다.

 

 

volatile이 효과적인 경우는 하나의 스레드가 wtite를 하고 다른 하나의 스레드가 read만 할 경우다. 이 경우 read만 하는 스레드는 CPU 캐시를 사용하고 다른 스레드가 write한 값을 즉각적으로 확인하지 못한다. volatile은 이런 경우 해당 자원에 가시성을 확보하게 해 줌으로써 동시성 이슈 해결에 도움을 줄 수 있다.

 

 

 

4. 쓰레드 안전한 객체 사용

Concurrunt 패키지를 통해 쓰레드 안전한 구조를 챙길 수 있다.

class Count {
    private AtomicInteger count = new AtomicInteger(0);
    public int view() {
            return count.getAndIncrement();
    }
}

 

5. 불변 객체

String 객체처럼 한번 만들면 그 상태가 변하지 않는 객체를 불변객체라고 한다. 불변 객체는 락을 걸 필요가 없다. 내부적인 상태가 변하지 않으니 여러 스레드에서 동시에 참조해도 동시성 이슈가 발생하지 않는다는 장점이 있다. 즉, 불변 객체는 언제라도 스레드 안전.

 

불변 객체는 객체의 상태를 변화시킬 수 있는 부분을 모두 제거해야한다. setter를 만들지 말고 final로 선언하면 된다.

 

 

 

참고:

'Java' 카테고리의 다른 글

[Java] 제네릭(Generic)  (0) 2021.07.11
[Java] Garbage Collection  (4) 2021.07.01
[Java] Static 키워드  (0) 2021.06.28
[Java] JVM 구조와 동작원리  (0) 2021.06.27
[Java] Thread/MultiThread 3 - 쓰레드풀  (0) 2021.06.19

지난번 JVM 포스팅에서 다음과 같은 개념을 다뤘다.

 

이때 Method Area에는 클래스, 인터페이스, 메소드, 필드, Static 변수가 보관되고

Heap Area에는 new 키워드로 생성된 객체와 배열이 보관된다고 했었다.

 

Method Area에 저장되는 Static 키워드는 지금까지 어디엔가 많이 붙혀서 써오긴 했는데 정확히 뭐하는 놈인지 모호했다. 이 기회에 정리해보자.

 

 

한 줄 요약

static 키워드를 사용한 변수는 클래스가 메모리에 올라갈 때 자동으로 생성이 된다.

즉, 인스턴스(객체) 생성 없이 바로 사용가능 하다. (그래서 편리하고 빠르다)

 

 

non-static 멤버 VS static 멤버

non-static 멤버

1. 공간적 특성: 멤버는 객체마다 별도로 존재한다.
    1.1. 인스턴스 멤버 라고 부른다.
2. 시간적 특성: 객체 생성 시에 멤버가 생성된다.
    2.1. 객체가 생길 때 멤버도 생성된다.
    2.2. 객체 생성 후 멤버 사용이 가능하다.
    2.3. 객체가 사라지면 멤버도 사라진다.
3. 공유의 특성: 공유되지 않는다.
    3.1. 멤버는 객체 내에 각각의 공간을 유지한다.

 

static 멤버

1. 공간적 특성: 멤버는 클래스당 하나가 생성된다.
    1.1. 멤버는 객체 내부가 아닌 별도의 공간에 생성된다.
    1.2. 클래스 멤버 라고 부른다.
2. 시간적 특성: 클래스 로딩 시에 멤버가 생성된다.
    2.1. 객체가 생기기 전에 이미 생성된다.
    2.2. 객체가 생기기 전에도 사용이 가능하다. (즉, 객체를 생성하지 않고도 사용할 수 있다.)
    2.3. 객체가 사라져도 멤버는 사라지지 않는다.
    2.4. 멤버는 프로그램이 종료될 때 사라진다.
3. 공유의 특성: 동일한 클래스의 모든 객체들에 의해 공유된다.

 

 

 

 

Static 키워드를 사용하는 이유

1. 자주 변하지 않는 일정한 값이나 설정 정보같은 공용자원에 대한 접근시 매번 메모리에 로딩하는 것 보다 비용을 줄일 수 있고 효율을 높일 수 있기 때문.

 

2. 인스턴스 생성 없이 바로 사용가능하기 때문에 프로그램에서 공통으로 사용되는 데이터들을 관리할 때 사용된다.

 

 

 

 

예제로 알아보자

class Foo{
	public static String s1 = "i am static";
	public String s2 = "i am instance";
}


public class static_practice {
	public static void main(String[] args) {
		System.out.println(Foo.s1);
		System.out.println(Foo.s2);	//에러
		
		Foo foo = new Foo();
		System.out.println(foo.s1);
		System.out.println(foo.s2); 
	}
}

 

Foo라는 클래스를 만들고 문자열 변수 두개를 초기화 시켰다. 한개는 static을 붙혔다.

여기서 주목할 것은 Foo.s1은 에러가 안떴고 Foo.s2는 에러가 발생했다는 것이다.

 

 

static 변수 s1은 new 키워드를 통해 객체를 생성하지 않아도 바로 사용할 수 있다. 반면 그냥 인스턴스 변수 s2는 객체를 생성한 후 접근이 가능하므로 에러가 발생한다.

 

 

 

 

class Foo{
	public static String s1 = "i am static";
	public String s2 = "i am instance";
}


public class static_practice {
	public static void main(String[] args) {
		//System.out.println(Foo.s1);
		//System.out.println(Foo.s2);
		
		Foo foo1 = new Foo();
		System.out.println("foo1.s1: "+foo1.s1);
		System.out.println("foo1.s2: "+foo1.s2);
		
		Foo foo2 = new Foo();
		System.out.println("foo2.s1: "+foo2.s1);
		System.out.println("foo2.s2: "+foo2.s2);
		
		System.out.println("====변경====");
		
		foo2.s1 = "changed s1 (foo2)";
		foo2.s2 = "changed s2 (foo2)";
		
		System.out.println("foo1.s1: "+foo1.s1);
		System.out.println("foo1.s2: "+foo1.s2);
		System.out.println("foo2.s1: "+foo2.s1);
		System.out.println("foo2.s2: "+foo2.s2);
	}
}

 

이번에는 static의 공유 속성을 알아보는 예제다.

 

결과부터 보면 이러하다.

 

====변경==== 부분에서 foo2객체의 값들을 다 바꾸었더니 건들지도 않은 foo1 객체의 static 변수 값도 바뀌었다

foo2의 값들은 당연히 바뀌는것이고.

 

Foo클래스의 static 변수는 해당 클래스의 모든 객체에 공통으로 사용되기 때문이다.

 

이처럼 static 변수, 메서드는 클래스 원형의 것을 참조하는 형태기에 한개라도 바뀌면 다 바뀐다.

 

 

 

참고:

개발을 하면서 jvm, jdk, jre와 같은 용어들을 많이 보며 지나갔는데 한번 정리가 필요할 것 같아서 글을 쓴다.

 

JVM이란

JVM은 자바 가상머신으로 자바 바이트코드를 실행 할 수 있는 주체로 JVM 덕분에 CPU나 플랫폼(OS+CPU아키텍처)과 독릭접으로 동작 가능하다. 예를들어 리눅스에서 컴파일한 C프로그램을 윈도우에서 실행했을때 환경이 달라서 작동하지 않는다는 문제가 있다고 한다. C/C++은 플랫폼 종속적이기에 크로스컴파일이 필요하다. 

 

 

자바는 JVM이 설치된 곳이라면 어디든 똑같이 작동한다. 하지만 이 JVM은 플랫폼 종속적이라는 모순이 있는데 어찌보면 당연한 것이다. 플랫폼이 다른 환경에서 일어나는 다양한 문제들을 JVM이 한번에 퉁쳐서 해결해 주기 때문이다. 또 JVM은 다른 하드웨어와 다르게 레지스터기반이 아닌 스택기반으로 동작한다. 이 부분도 해당 플랫폼이 레지스터를 사용하는지 안하는지 알 수 없기에 플랫폼 독립적으로 사용할 수 있는 스택을 사용하는 것이다.

 

자바 프로그램은 우리가 작성한 소스코드가 JAVAC compiler를 통해 바이트코드로 변환되고 이를 JVM이 읽어들이는 방식으로 진행된다.

 

 

 

 

JVM구조

JVM의 구조는 크게 보면, Garbage Collector, Execution Engine, Class Loader, Runtime Data Area로, 4가지로 나눌 수 있다. 

 

 

1. Class Loader

JVM 내로 클래스 파일을 로드하고, 링크를 통해 배치하는 작업을 수행하는 모듈이다. 런타임 시에 동적으로 클래스를 로드한다.

 

2. Execution Engine

클래스 로더를 통해 JVM 내의 Runtime Data Area에 배치된 바이트 코드들을 명렁어 단위로 읽어서 실행한다. 최초 JVM이 나왔을 당시에는 인터프리터 방식이었기때문에 속도가 느리다는 단점이 있었지만 JIT 컴파일러 방식을 통해 이 점을 보완하였다. JIT는 바이트 코드를 어셈블러 같은 네이티브 코드로 바꿈으로써 실행이 빠르지만 역시 변환하는데 비용이 발생한다. 이 같은 이유로 JVM은 모든 코드를 JIT 컴파일러 방식으로 실행하지 않고, 인터프리터 방식을 사용하다가 일정한 기준이 넘어가면 JIT 컴파일러 방식으로 실행한다.

 

 

3. Garbage Collector

Garbage Collector(GC)는 힙 메모리 영역에 생성된 객체들 중에서 참조되지 않은 객체들을 탐색 후 제거하는 역할을 한다. 이때, GC가 역할을 하는 시간은 언제인지 정확히 알 수 없다.

 

 

4. Runtime Data Area

JVM의 메모리 영역으로 자바 애플리케이션을 실행할 때 사용되는 데이터들을 적재하는 영역. 이 영역은 크게 Method Area, Heap Area, Stack Area, PC Register, Native Method Stack로 나눌 수 있다. 

 

Method Area, Heap Area는 모든 쓰레드에서 공유, 나머지는 쓰레드마다 각각 존재한다.

 

이 Runtime Data Area를 살펴보자

 

4.1. Method area 

모든 쓰레드가 공유하는 메모리 영역. 메소드 영역은 클래스, 인터페이스, 메소드, 필드, Static 변수 등의 바이트 코드를 보관한다.

 

 

4.2. Heap area

모든 쓰레드가 공유하며, new 키워드로 생성된 객체와 배열이 생성되는 영역. 또한, 메소드 영역에 로드된 클래스만 생성이 가능하고 Garbage Collector가 참조되지 않는 메모리를 확인하고 제거하는 영역이다.

 

 

4.3. Stack area 

 

메서드 호출 시마다 각각의 스택 프레임(그 메서드만을 위한 공간)이 생성한다. 그리고 메서드 안에서 사용되는 값들을 저장하고, 호출된 메서드의 매개변수, 지역변수, 리턴 값 및 연산 시 일어나는 값들을 임시로 저장한다. 마지막으로, 메서드 수행이 끝나면 프레임별로 삭제한다.

 

 

4.4. PC Register

쓰레드가 시작될 때 생성되며, 생성될 때마다 생성되는 공간으로 쓰레드마다 하나씩 존재한다. 쓰레드가 어떤 부분을 무슨 명령으로 실행해야할 지에 대한 기록을 하는 부분으로 현재 수행중인 JVM 명령의 주소를 갖는다.

 

 

4.5. Native method stack

자바 외 언어로 작성된 네이티브 코드를 위한 메모리 영역.

 

 

 

 

 

 

참고:

https://steady-coding.tistory.com/305

 

JVM 메모리 구조란? (JAVA)

안녕하세요? 코딩 중독입니다. 오늘은 JVM 메모리 구조에 대해 알아보겠습니다. JVM이란? JVM 메모리 구조를 설명하기 전에 JVM이 무엇인지 알아야 합니다. JVM은 Java Virtual Machine의 약자로, 자바 가상

steady-coding.tistory.com

https://youtu.be/UzaGOXKVhwU

 

+ Recent posts