들어가며

String의 불변성 - StringBuilder와 StringBuffer의 차이점을 다뤘습니다. 이번에는 String + 연산에 대해서 조금 더 자세하게 다뤄보고도록 하겠습니다. 이전 글을 보고 오시는것을 추천드립니다.

 

 

[JAVA] StringBuilder, StringBuffer 의 차이점과 주의사항

들어가며이 글에서는 StringBuilder, StringBuffer 차이점뿐만 아니라 사용 이유에 대해서도 다루고있습니다. [JAVA] String의 불변성(immutability)들어가며JAVA를 처음 접할 때, String은 불변객체라고 배우고

tmd8633.tistory.com

 

 

 

이 글을 쓰는 이유

지난시간에 String 의 불변성과 StringBuilder에 대해서 알아보았습니다. 이전 글들을 작성하면서 String + 연산이 최적화 되었다는 얘기를 수 없이 보았습니다.

 

        String[] arr = {"2", "3", "4"};
        String str = "1";

        for (String s : arr) {
            str += s;
        }

이렇게 String + 연산을 하게되면 String의 불변성 때문에 "12", "123", "1234"의 메모리가 heap 영역에 저장되고 GC 대상도 증가하게 되는 것은 이제 충분히 이해가 됩니다.

그런데 이 과정을 JDK 5 부터 컴파일 시에 내부적으로 StringBuilder를 사용해서 최적화를 하였고, 완전히 최적화가 되지않아서(매번 StringBuilder객체가 생성되는 문제 등) JDK 9부터 지금까지 StringConcatFactory가 사용되어 + 연산이 완전 최적화 되었다고 했습니다. 이를 실제로 알아보기 위해 bytecode를 까봤습니다.

 

 

public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 13 L0
    ICONST_3
    ANEWARRAY java/lang/String
    DUP
    ICONST_0
    LDC "2"
    AASTORE
    DUP
    ICONST_1
    LDC "3"
    AASTORE
    DUP
    ICONST_2
    LDC "4"
    AASTORE
    ASTORE 1
   L1
    LINENUMBER 14 L1
    LDC "1"
    ASTORE 2
   L2
    LINENUMBER 16 L2
    ALOAD 1
    ASTORE 3
    ALOAD 3
    ARRAYLENGTH
    ISTORE 4
    ICONST_0
    ISTORE 5
   L3
   FRAME FULL [[Ljava/lang/String; [Ljava/lang/String; java/lang/String [Ljava/lang/String; I I] []
    ILOAD 5
    ILOAD 4
    IF_ICMPGE L4
    ALOAD 3
    ILOAD 5
    AALOAD
    ASTORE 6
   L5
    LINENUMBER 17 L5
    ALOAD 2
    ALOAD 6
    INVOKEDYNAMIC makeConcatWithConstants(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/invoke/StringConcatFactory.makeConcatWithConstants(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
      // arguments:
      "\u0001\u0001"
    ]
    ASTORE 2
   L6
    LINENUMBER 16 L6
    IINC 5 1
    GOTO L3
   L4
    LINENUMBER 19 L4
   FRAME CHOP 3
    RETURN
   L7
    LOCALVARIABLE s Ljava/lang/String; L5 L6 6
    LOCALVARIABLE args [Ljava/lang/String; L0 L7 0
    LOCALVARIABLE arr [Ljava/lang/String; L1 L7 1
    LOCALVARIABLE str Ljava/lang/String; L2 L7 2
    MAXSTACK = 4
    MAXLOCALS = 7
}

 

StringConcatFactory.makeConcatWithConstants 메소드가 실제로 String + 연산에 사용되고 있었습니다.

 

그럼 직접 StringBuilder를 사용하지 않고 String + 연산을 해도 최적화가 되는건가? 라는 생각에 테스트를 진행했습니다.

속도테스트는 100배가량 +연산이 느렸고, 메모리도 낭비되는 것을 확인할 수 있었습니다.

 

그럼 무슨 최적화가 되었다는걸까요? 그 결과를 글로 적고싶어서 이렇게 남깁니다.

 

 

Concat

String의 concat() 메소드를 사용해보셨나요? 앞문자열과 뒷문자열을 합쳐주는 기능을 수행하는 메소드입니다.

StringConcatFactory는 그 concat 메소드처럼 여러 문자열을 하나로 합쳐주는 역할을 수행하는 것이었습니다.

 

String str1 = "1";
String str2 = str1 + "2" + "3" + "4" + "5" + "6" + "7" + "8" + "9";

 

JDK5 이전에는 이런 String + 연산을 하면 "12", "123", "1234" ... 이런 데이터가 저장이 되었지만, StringConcatFactory는 이런 연속적인 문자열 합치는 것을 한번에 수행하면서 최적화가 되는 것입니다.

 

   L0
    LINENUMBER 8 L0
    LDC "1"
    ASTORE 1
   L1
    LINENUMBER 9 L1
    ALOAD 1
    INVOKEDYNAMIC makeConcatWithConstants(Ljava/lang/String;)Ljava/lang/String; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/invoke/StringConcatFactory.makeConcatWithConstants(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
      // arguments:
      "\u000123456789"
    ]
    ASTORE 2

저는 str1 + "2" + "3' ... + "9" 까지 각각 문자열을 더했지만 이를 StringConcatFactory 에서 "23456789" 문자열로 하나로 합쳐 불필요한 메모리 낭비를 최적화 한겁니다. 그러니까 배열에서 하니씩 꺼내며 + 연산할때에는 최적화가 안되었던것이지요.

 

그러니까 String + 연산 은 절대하지말고 StringBuilder, StringBuffer나 쓰십쇼

들어가며

        int[] arr = {1,2,3,4,5};
        int sum = 0;
        
        // for문
        for (int i = 0; i < arr.length; i++) {
            sum += arr[i];
        }

        // 향상된 for문
        for (int i : arr) {
            sum += i;
        }

 

향상된 for문은 JAVA 5에서 추가되었습니다. 이전 for문 보다 가독성이 좋아졌고, 더욱 간결해졌습니다.

이번 글에서는 for문과 향상된 for문에 어떤 차이가 있는지 알아보도록 하겠습니다.

 

 

 

for문

        for (int i = 0; i < arr.length; i++) {
            sum += arr[i];
        }

이 부분에 대해서 bytecode를 확인해보겠습니다.

 

L3
FRAME APPEND [[I I I]
    ILOAD 3        // i 값 로드
    ALOAD 1        // 배열 arr 로드
    ARRAYLENGTH    // 배열 길이를 가져옴
    IF_ICMPGE L4   // i가 배열 길이와 같거나 크면 L4로 점프
L5
    ILOAD 2        // sum 값 로드
    ALOAD 1        // 배열 arr 로드
    ILOAD 3        // i 값 로드
    IALOAD        // 배열 arr[i] 값 로드
    IADD          // sum + arr[i] 계산
    ISTORE 2      // 결과를 sum에 저장
L6
    IINC 3 1      // i++
    GOTO L3       // 다시 조건 검사로 이동

 

바이트코드를 해석해본다면 인덱스 i를 매번 증가시키고, 배열의 해당 인덱스에 접근해서 값을 더하는 방식입니다.

중요한건 인덱스를 통해 값을 가져오는 방식이라는 것입니다. 인덱스 개념이 없는 HashMap 과 같은 데이터에서는 사용할 수 없습니다. 배열구조를 가진 데이터에서 데이터를 로드할 수 있습니다.

 

 

 

향상된 for문

 

 

L3
FRAME FULL [[Ljava/lang/String; [I I [I I I] []
    ILOAD 5        // 인덱스 로드
    ILOAD 4        // 배열 길이 로드
    IF_ICMPGE L4   // 인덱스가 배열 길이와 같거나 크면 L4로 점프
    ALOAD 3        // 배열 arr 로드
    ILOAD 5        // 인덱스 로드
    IALOAD        // 배열 arr[인덱스] 값 로드
    ISTORE 6       // 로컬 변수 6에 저장 (배열 요소)
L5
    ILOAD 2        // sum 값 로드
    ILOAD 6        // 로컬 변수 6(배열 요소) 로드
    IADD          // sum + 배열 요소 계산
    ISTORE 2      // 결과를 sum에 저장
L6
    IINC 5 1      // 인덱스 증가
    GOTO L3       // 다시 조건 검사로 이동

 

여기에서 눈 여겨볼 부분은 로컬 변수 6입니다. 일반 for문은 인덱스를 통해 데이터를 바로 sum에 저장하는 방식이었습니다.

하지만 향상된 for문은 로컬변수6에 arr[인덱스] 를 저장하고 로컬변수6을 sum에 저장하는 방식입니다.

 

왜 이런 번거로운 과정이 추가된걸까요?

 

 

Iterator 안전성

향상된 for문이 좋은점은 배열 뿐만이 아니고 Iterator 에서 동작할 수 있다는 것입니다. Iterable 은 Collection 인터페이스에서 상속 받고 있기때문에 컬렉션(List, Set, Map 등) 에서도 향상된 for문을 동작할 수 있다는 것이죠. 이를 위해 Iterator나 내부적인 인덱스 처리로 컬렉션을 순회하는데, 컬렉션의 상태가 중간에 변경될 가능성을 방지하는 안전한 구조가 필요하기 때문입니다.

 

        List<Integer> arr = List.of(1,2,3,4,5);
        int sum = 0;
        
        // 이 코드는
        for (int i : arr) {
            sum += i;
        }
        
        // Iterator 와 동일하다
        Iterator<Integer> iterator = arr.iterator();
        while (iterator.hasNext()) {
            sum += iterator.next();
        }

 

List의 향상된 for문

   L3
   FRAME APPEND [java/util/List I java/util/Iterator]
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.hasNext ()Z (itf)
    IFEQ L4
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object; (itf)
    CHECKCAST java/lang/Integer
    INVOKEVIRTUAL java/lang/Integer.intValue ()I
    ISTORE 4
   L5
    LINENUMBER 15 L5
    ILOAD 2
    ILOAD 4
    IADD
    ISTORE 2
   L6
    LINENUMBER 16 L6
    GOTO L3

 

 

List의 Iterator

   L3
    LINENUMBER 13 L3
   FRAME APPEND [java/util/List I java/util/Iterator]
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.hasNext ()Z (itf)
    IFEQ L4
   L5
    LINENUMBER 14 L5
    ILOAD 2
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object; (itf)
    CHECKCAST java/lang/Integer
    INVOKEVIRTUAL java/lang/Integer.intValue ()I
    IADD
    ISTORE 2
    GOTO L3

 

향상된 for문은 컴파일 시 iterator를 사용합니다.

 

 

재사용과 성능최적화

        for (int i : arr) {
            System.out.println(i);
            sum += i;
        }

for 문 안에서 i의 값을 여러번 사용된다면 어떻게 될까요?

 

   L3
   FRAME FULL [[Ljava/lang/String; [I I [I I I] []
    ILOAD 5
    ILOAD 4
    IF_ICMPGE L4
    ALOAD 3
    ILOAD 5
    IALOAD
    ISTORE 6
   L5
    LINENUMBER 11 L5
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ILOAD 6
    INVOKEVIRTUAL java/io/PrintStream.println (I)V
   L6
    LINENUMBER 12 L6
    ILOAD 2
    ILOAD 6
    IADD
    ISTORE 2
   L7
    LINENUMBER 10 L7
    IINC 5 1
    GOTO L3

 

L3에서 ISTORE 6 에 i 값을 저장하고

L5와 L6에서 ILOAD 6 을 통해 로컬변수 6을 로드해 재사용하고 있는 것을 확인할 수 있었습니다. 로컬변수6 데이터를 저장하므로써 단 한번 배열에 접근하는 것으로 다음의 데이터를 모두 처리할 수 있게되었습니다.

 

 

 

읽기전용

향상된 for문에서 배열이나 컬렉션의 요소를 순회할 때, 해당 요소는 로컬변수에 저장되기때문에 읽기 전용상태로 처리됩니다. 즉 복사본을 사용하기때문에 안전한 처리가 가능해집니다.

 

        int[] arr = {1,2,3,4,5};

        for (int i : arr) {
            i = 100;
        }

 

i 는 로컬변수에서 로드된 데이터이기 때문에 i에 직접 할당할 수 없습니다.

다만 참조타입인 경우 얕은복사로 인해 내부데이터의 변경은 가능합니다.

 

    public class Item {

        public int value;

        public Item(int value) {
            this.value = value;
        }
    }

    public static void main(String[] args) {
        List<Item> items = List.of(new Item(1), new Item(2));

        for (Item item : items) {
            item.value = 100;
        }

        for (Item item : items) {
            System.out.println(item.value); // 100
        }
    }

 

 

일관성

향상된 for문은 모든 배열과 Iterable 객체에서 동작하게 설계되었습니다.따라서 배열이 아닌 다른 컬렉션 객체를 다룰 때도 일관성 있게 순회하기 위해, 배열의 요소를 먼저 임시 변수에 저장하고 그 후에 작업을 수행하는 것이 통일된 방식이라고 할 수 있겠습니다.

 

 

 

 

마무리

향상된 for문은 시작, 끝 인덱스를 지정할 수 없습니다. 무조건 전체 순회만 가능하다는 것이죠.

그리고 순회 중에는 데이터를 삭제가 불가능합니다. 시작할 때 전체 길이를 저장하는데 중간에 전체길이가 달라지면 예외가 발생합니다.

 

일반 for문, 향상된 for문간에는 성능차이가 있을까요? 우리는 위에 bytecode를 직접 확인해봤습니다. 향상된 for문에서 추가적으로 로컬변수를 사용하는 차이가 있지만 이는 무시할 정도이고, 로컬변수 도입으로 인한 재사용성으로 인한 최적화도 무시할 정도라고 생각이 됩니다. 따라서 성능차이는 없다고 생각합니다.

 

 

결론은 향상된 for문은 Iterable 객체를 손쉽게 쉽게 사용하기 위해 만들어졌다! 그리고 컴파일 시에 로컬변수를 사용된다!

개요

if-else 와 switch문은 성능 상 약간의 차이가 있습니다. 오늘은 그 구조를 이해해보고 언제 사용해야 좋을지 바이트코드를 알아보도록 하겠습니다.

 

 

if

int i = 1;

if (i == 1) {
    method1();
} else if (i == 3) {
    method2();
} else {
    method3();
}

 

int i의 값이 1일때 method1()을 실행하고, i의 값이 3일때 method2()를 실행하고, 나머지는 method3()을 실행하는 아주 간단한 구조를 가진 if 문입니다. 이 코드를 bytecode로 변환해보겠습니다.

public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 7 L0
    ICONST_1
    ISTORE 1
   L1
    LINENUMBER 9 L1
    ILOAD 1
    ICONST_1
    IF_ICMPNE L2
   L3
    LINENUMBER 10 L3
    INVOKESTATIC functional/Main.method1 ()V
    GOTO L4
   L2
    LINENUMBER 11 L2
   FRAME APPEND [I]
    ILOAD 1
    ICONST_3
    IF_ICMPNE L5
   L6
    LINENUMBER 12 L6
    INVOKESTATIC functional/Main.method2 ()V
    GOTO L4
   L5
    LINENUMBER 14 L5
   FRAME SAME
    INVOKESTATIC functional/Main.method3 ()V
   L4
    LINENUMBER 17 L4
   FRAME SAME
    RETURN
   L7
    LOCALVARIABLE args [Ljava/lang/String; L0 L7 0
    LOCALVARIABLE i I L1 L7 1
    MAXSTACK = 2
    MAXLOCALS = 2

 

구분을 좀 나누면 이렇게 됩니다.

구조를 잘 보시면

  1. L1에서 조건이 일치하면 L3으로 이동하여 method1을 실행하고, 일치하지 않으면 IF_ICMPNE L2 로 이동합니다.
  2. L2에서 조건이 일치하면 L6으로 이동하여 method2를 실행하고, 일치하지 않으면 IF_ICMPNE L5 로 이동합니다.
  3. else 에서 method3을 실행합니다.

if 문은 결과가 true 가 나오기전까지 순서대로 비교연산을 수행해야합니다. 만약 비교문이 10개라면 최대 10번 비교연산이 수행될 수 있단는 것입니다.

 

 

 

switch

 

int i = 1;

switch (i) {
    case 1 -> method1();
    case 3 -> method2();
    default -> method3();
}
  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 7 L0
    ICONST_1
    ISTORE 1
   L1
    LINENUMBER 9 L1
    ILOAD 1
    LOOKUPSWITCH
      1: L2
      3: L3
      default: L4
   L2
    LINENUMBER 10 L2
   FRAME APPEND [I]
    INVOKESTATIC functional/Main.method1 ()V
    GOTO L5
   L3
    LINENUMBER 11 L3
   FRAME SAME
    INVOKESTATIC functional/Main.method2 ()V
    GOTO L5
   L4
    LINENUMBER 12 L4
   FRAME SAME
    INVOKESTATIC functional/Main.method3 ()V
   L5
    LINENUMBER 15 L5
   FRAME SAME
    RETURN
   L6
    LOCALVARIABLE args [Ljava/lang/String; L0 L6 0
    LOCALVARIABLE i I L1 L6 1
    MAXSTACK = 1
    MAXLOCALS = 2

 

 

switch 문을 바이트코드로 변환했을 때, 이 부분에서 switch 문이 발생된걸 확인할 수 있습니다. 위에 if-else 문과 구조가 다른데요 눈여겨 볼 것은 LOOKUPSWITCH 입니다.

LOOKUPSWITCH 는 조건마다 비교연산을 하는게 아니고 표현식 값을 가지고 탐색합니다.

if-else 보다 비교연산 없이 탐색하기 때문에 효율적이지만, jump table 생성 비용의 오버헤드가 있습니다.

 

LOOPUPSWITCH, TABLESWITCH 에 대해서  조금 더 Alaboza

더보기

TABLESWITCH

int i = 1;

int j = switch (i) {
    case 0 ->  0;
    case 1 ->  1;
    case 2 ->  2;
    case 5 ->  5;
    case 10 ->  10;
    default -> -1;
};

 이 switch문을 바이트 코드로 바꾸면 어떻게 될까?

 

   L1
    LINENUMBER 9 L1
    ILOAD 1
    TABLESWITCH
      0: L2
      1: L3
      2: L4
      3: L5
      4: L5
      5: L6
      6: L5
      7: L5
      8: L5
      9: L5
      10: L7
      default: L5

우리는 0, 1, 2, 5, 10 에대한 case를 작성했는데 바이트 코드를 보면 TABLESWITCH 안에 0 - 10까지의 데이터가 들어가있습니다. 0, 1, 2, 5, 10을 제외한 나머지 숫자들은 switch 종료 라인인 L5 를 가리키고 있습니다.

 

 TABLESWITCH는 전체 범위를 인덱싱 하기 때문인데요. 이 과정에서 사용되지 않은 빈 공간까지 차지하게 되어버렸습니다.

 

LOOKUPSWITCH와 TABLESWITCH는 컴파일과정에서 효율성을 따져서 자동으로 이 방식이 선택됩니다.

만약 인덱싱과정이 사용된다면 더더욱 switch문을 써야겠죠?

 

 

그래서 뭐가 더 좋을까?

코드를 작성할 때, 성능도 중요하지만 가독성에 대한 것도 고려해야합니다. JAVA 17부터 switch 문이 업데이트되면서 기존의 if-else 보다 가독성이 좋아졌습니다.

 

// switch 문
switch (i) {
    case 1:
        method1();
        break;
    case 3:
        method2();
        break;
    default:
        method3();
        break;
}

// JAVA 17 에서의 switch 문

switch (i) {
    case 1 -> method1();
    case 3 -> method2();
    default -> method3();
}

switch (i) { // 다수 조건식
    case 1,2 -> method1();
    case 3 -> method2();
    default -> method3();
}


// JAVA 21 에서의 switch 문 
switch (animal) { // 타입 캐스팅
    case Cat c -> c.sound();
    case Dog d -> d.sound();
    case Cow c -> c.sound();
    default -> "Unknown";
};

switch (animal) { // Null 처리
    case Cat c -> c.sound();
    case Dog d -> d.sound();
    case Cow c -> c.sound();
    case null -> "Null";
    default -> "Unknown";
};

 

 

따라서, 가독성에 중점을 두거나 조건이 많을때는 switch 문을 사용하는것도 고려해볼만 합니다.

들어가며

이 글에서는 StringBuilder, StringBuffer 차이점뿐만 아니라 사용 이유에 대해서도 다루고있습니다.

 

[JAVA] String의 불변성(immutability)

들어가며JAVA를 처음 접할 때, String은 불변객체라고 배우고 지나갔습니다. 하지만 실제 String을 다루다보면 어째서 불변객체인지 의문이 들곤합니다.  String str = "apple"; str = "banana";왜냐하면, 재할

tmd8633.tistory.com

 

이전 글에서 String의 불변성에 대해서 읽어보시는걸 추천드립니다.

 

 

불변성

String 은 불변성입니다.

StringBuffer, StringBuilder 는 가변성입니다.

 

        String str = "1";
        System.out.println("hashCode = " + str.hashCode());
        System.out.println("====================================");
        String[] arr = {"2", "3", "4"};
        for (String s : arr) {
            str += s;
            System.out.println("hashCode = " + str.hashCode());
        }
//        hashCode = 49
//        ====================================
//        hashCode = 1569
//        hashCode = 48690
//        hashCode = 1509442

 

String은 불변객체이기때문에 String Constant Pool에서 str += s 될때마다 불필요한 새로운 데이터를 heap 영역에 저장합니다.

System.out.println("12".hashCode()); // 1569

 

실제로 중간과정에서 만들어진 String의 hashCode를 가져와보면 Pool에서 불필요한 데이터가 저장된걸 볼 수 있습니다.

 

 

여기에서 StringBuffer or StringBuilder를 사용하면 어떻게 될까요?

        StringBuilder sb = new StringBuilder("1");
        System.out.println("hashCode = " + sb.hashCode());
        System.out.println("====================================");
        String[] arr = {"2", "3", "4"};
        for (String s : arr) {
            sb.append(s);
            System.out.println("hashCode = " + sb.hashCode());
        }
//        hashCode = 918221580
//        ====================================
//        hashCode = 918221580
//        hashCode = 918221580
//        hashCode = 918221580

가변객체이기때문에 hashCode가 변하지 않은것을 확인할 수 있습니다.

 

String의 불변특성때문에 문자열 더하기의 연산은 불필요한 데이터를 저장하게되며, hashCode의 캐싱에도 불필요한 데이터가 저장된다는 문제가 있습니다. 따라서 String의 문자열 더하기는 StringBuilder 나 StringBuffer를 사용하도록 해야합니다.

 

그러면 StringBuilder와 StringBuffer는 무슨 차이가 있을까요?

 

 

StringBuilder vs StringBuffer

 

Builder와 Buffer는 같은 추상클래스를 상속받고 있고, 그 구조가 완벽하게 동일합니다. 그 차이점은 구현체에서 찾을 수 있습니다.

 

StringBuffer는 동기화를 지원하고 (Thread-Safe),

StringBuilder는 동기화를 지원하지 않습니다.

 

 

 

StringBuffer 대부분의 메소드에는 synchronized 키워드가 붙어있습니다.

 

 

 

언제 어떻게 사용하면 될까?

 

StringBuffer

동기화를 지원하기때문에 멀티스레드 환경에서 안전하게 동작합니다. 동시성 문제를 고려해야한다면 StringBuffer를 사용해야합니다.

 

StringBuilder

StringBuilder는 StringBuffer 보다 일반적으로 성능상 빠릅니다. 동기화 처리가 없기 때문입니다. 따라서 단일스레드 환경에서는 불필요한 동기화 처리를 하지 않는 StringBuilder를 사용해야합니다.

 

 

 

사용 시 주의점

 

1. capacity

StringBuilder, StringBuffer 둘다 용량증가정책은 동일하게 설정되어있습니다. 이 capacity는 문자열의 수용량입니다. 기본 capacity는 16입니다.

 

 

 

Collection List를 최적화해보자

Collection List Collection중에 가장 많이 사용하는 것은 아마도 List일것입니다. List에는 여러 구현체가 있습니다. ArrayList, LinkedList, Vector 가 있는데 오늘 알아볼것은 ArrayList와 LinkedList입니다. 최적화

tmd8633.tistory.com

이전 글에서 List의 최적화를 다뤘는데 List의 initialCapacity와 같은 개념입니다.

 

 

append 메소드를 사용할때, capacity가 가득차면 추상클래스에서 ensureCapacityInternal 메소드를 통해 capacity를 2배증가시킵니다.

 

이 과정에서 StringBuffer는 동기화 처리과정에서 오버헤드가 발생할 수 있습니다. (락 획득과 해제, 락에 의한 대기시간 증가 등)

 

capacity는 생성자에서 사용자가 조절할 수 있습니다.

 

 

 

2. null 처리

        String str = null;

        StringBuilder sb = new StringBuilder("1");
        sb.append(str);
        System.out.println("sb = " + sb); // sb = 1null

        StringBuffer sb2 = new StringBuffer("1");
        sb2.append(str);
        System.out.println("sb2 = " + sb2); // sb2 = 1null

        String a = "1";
        a += str;
        System.out.println("a = " + a); // a = 1null

 

StringBuilder, StringBuffer 에 null이 append 되었을 때 따로 처리해주지 않습니다. null 이 들어오면 "null" 문자열로 인식되어 append 됩니다. 사용하기 전에 null 처리를 반드시 해야됩니다.

들어가며

JAVA를 처음 접할 때, String은 불변객체라고 배우고 지나갔습니다. 하지만 실제 String을 다루다보면 어째서 불변객체인지 의문이 들곤합니다.

 

        String str = "apple";
        str = "banana";

왜냐하면, 재할당에 대해서 문제가 없으니까요.

 

그런데 무슨 불변성이 있다는 것일까요? 이 글에서는 String 이 왜 불변성을 가지는지 알아보겠습니다.

 

 

1. String은 참조타입이다.

String의 불변성을 얘기하기 전에 알고 넘어가야할 부분이 몇가지 있습니다.JAVA에서 String 은 기본타입이 아닌 참조타입인 클래스라는 것입니다.

 

        String str1 = "apple";
        String str2 = "apple";
        System.out.println(str1 == str2); // true
        
        String str1 = new String("apple");
        String str2 = new String("apple");
        System.out.println(str1 == str2); // false

 

"" (큰따옴표)로 생성한 객체는 내용이 같으면 같음 메모리 주소를 가집니다.

new 연산자로 생성한 객체는 내용이 같더라도 다른 메모리 주소를 가집니다.

 

 

2. String Constant Pool

String은 Heap 영역에서 String Constant Pool 에서 관리되고있습니다.

""(큰따옴표)로 생성된 객체는 String Constant Pool 에서 관리되어 다음 동일한 값이 할당된다면 이전에 있던 참조값을 반환해줍니다. ( ==  true)

new 연산자를 사용한다면 String Constant Pool에 같은 값이 존재하더라도 별도의 주소를 가리키게됩니다. ( == false)

 

만약 위 그림에서 str2 = "apple" 이었다가 "banana"로 값이 변경된다면 어떻게 될까요?

변수 str2의 값이 "banana"로 변경된다면 String Constant Pool에서 값이 "banana"인 주소를 찾고 없다면 새로 생성하여 그 주소를 반환해줍니다. 이 과정에서 이전의 "apple" 데이터는 변경되지 않습니다.

 

 

 

불변인 이유

  • String Constant Pool에서 String 을 관리한다는것은 동일한 데이터에 대해 메모리주소를 공유함으로써  Heap 영역의 메모리가 절약한다는 뜻이됩니다. 이 과정에서 String 이 불변성을 유지해야지 메모리 주소를 공유하는 것이 가능합니다.
    만약 String이 불변객체가 아니라면 변수 str1과 str2가 "apple" 의 메모리주소를 공유하고있다고했을때 str2에서 값이 변경되는 순간 str1도 값이 변경되기 때문에 불변성을 가져야합니다.
  • String 객체에 대해 hashCode를 미리 계산하지 않아 메모리를 절약하고, 필요할 때만 계산하고, 계산된 후에는 재사용하여 성능을 향상 시킵니다. hashCode를 재사용한다는 것은 String의 불변성이 보장되어야 가능합니다. hashCode의 캐싱은 HashMap, HashSet과 같은 hash를 사용하는 연산에서 효과적입니다. 일반 객체는 사용할때마다 hashCode를 계산하지만 String은 캐싱된 hashCode를 사용하기 때문에 더욱 빠르게 연산하는 것이 가능합니다.
  • Sring은 불변이기 때문에 Thread-Safe합니다. 값이 변경되지않는 것이 보장된다면 동기화 문제가 발생할 수가 없습니다.
  • String이 불변이 아니라면 보안에 문제가 생길 수 있습니다. 데이터베이스의 username, password이나 host, port가 String값으로 전달된다고 했을때, 이 값이 불변이 아니라면 공격으로부터 값이 변경될 수 있기 때문입니다.

 

이상 String의 불변성에 대해서 알아보았습니다. 문자열의 기본타입이 존재하지않는 이유가 String의 불변성과 관련이 있다는 것을 알아가는 시간이었습니다. 

 

android 빌드를 할때 

flutter Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.8.0, expected version is 1.6.0.

라는 메세지가 떴습니다.

 

 

android - settings.gradle

 

com.android.application version 을 7.3.0 -> 7.4.2 로 변경하면 됩니다.

'Language > Flutter' 카테고리의 다른 글

[Flutter] 상태 관리 기초 - setState  (0) 2024.11.18
[Dart] Spread 연산자(...)  (0) 2024.11.17
[Dart] Null Safety  (0) 2024.11.16
[Dart] var, dynamic, final, late, const 키워드  (0) 2024.11.15
[Flutter] Widget의 크기제약  (0) 2024.11.07

함수형 인터페이스가 뭘까

함수형 인터페이스는 1개의 추상메소드를 가지고 있는데요. 당연하게도 아무런 역할을 하지 않는 인터페이스입니다.

java.util.function

 

이런 함수형 인터페이스는 function 패키지에 담겨져있습니다.

 

그 내용을 열어보면

많은 인터페이스들이 존재합니다. 이 함수형 인터페이스에는 똑같은 특징이 있는데 @FunctionalInterface 어노테이션을 사용하고있다는 겁니다. 이 어노테이션은 해당 인터페이스가 함수형 인터페이스 조건에 맞는지 검사해준다고 하는데요. 그렇지만 이 어노테이션을 사용하지 않더라도 동작하는데에는 문제가 없으니 참고해주시면 될것 같습니다.

 

함수형 인터페이스 정의

  1. 추상메소드가 단 한개인 인터페이스
  2. static, default 메소드가 여러개여도 추상메소드가 단 한개인 인터페이스

 

 

람다표현식

자바 8 이상을 사용하시는 분들은 lambda 에 대해서 익숙하실 겁니다. 함수형 인터페이스의 목적은 람다표현식을 이용해 함수형 프로그래밍을 구현하기 위해서라고 할 수 있습니다.

 

 

 

 

함수형 인터페이스 종류

function 패키지 이외에 Callable, Comparator 등의 함수형 인터페이스가 있지만 이 글에서는 function 패키지 내에서만 설명하겠습니다.

인터페이스 메소드 설명
Runnable void run() 매개변수를 받지 않고, 반환하지 않습니다.
Thread 에서 Runnable 을 사용하고있습니다.
Consumer<T> void accept(T t) 매개변수를 받고, 리턴하지 않을때 사용합니다.
forEach 메소드에서 이 인터페이스를 사용하고있습니다.
Function<T, R> R apply(T t) 제네릭 첫번째 타입을 매개변수로 받고,제네릭 두번째 타입을 반환합니다. Stream 에 map 메소드에서 사용하고있습니다.
Predicate<T> boolean test(T t) 매개변수를 받아 true / false 인지를 반환합니다.
Stream 에 filter에서 사용하고있습니다.
Supplier<T> T get() 매개변수를 받지않고, 어떤 값을 반환합니다.
Optional에 or(),orElseThrow() 등 메소드에서 사용하고있습니다.

 

 

 

 

1. Runnable

함수형 인터페이스 중에서 가장 애매한게 Runnable 이라고 생각합니다. 매개변수를 받지도 않고 반환값도 없는 이 인터페이스는 도대체 어디에 사용되는걸까요?

 

Thread 는 Runnable을 생성자 파라미터로 받아 사용하고 있는 것을 확인할 수 있습니다.

        Thread t = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                System.out.println(i);
            }
        });
        t.start();

 

Thread 에서 어떤 메소드를 실행할지에 대한 표현을 Runnable 를 사용하여 구현하였습니다. 이 Runnable 내에는 어떠한 함수가 와도 타입으로부터 자유롭습니다. 

 

 

 

2. Consumer

인터페이스 메소드 설명
Consumer<T> void accept(T t) 1개의 타입을 받음
BiConsumer<T, U> void accept(T t, U u) 2개의 타입을 받음
IntConsumer void accept(int value) 1개의 int 값을 받음
LongConsumer void accept(long value) 1개의 long 값을 받음
DoubleConsumer void accept(double value) 1개의 double 값을 받음
ObjIntConsumer<T> void accept(T t, int value) 1개의 타입과 1개의 int 값을 받음
ObjLongConsumer<T> void accept(T t, long value) 1개의 타입과 1개의 long 값을 받음
ObjDoubleConsumer<T> void accept(T t, double value) 1개의 타입과 1개의 double 값을 받음

 

 

 

@FunctionalInterface
public interface Consumer<T> {

    void accept(T t);

    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
    
}

 

이 Consumer 는 forEach 메소드에서 사용하는 것 외에는 어디다가 이 메소드를 사용해야 유용하지? 라는 의문이 들었습니다. 고민을 하다가 Consumer 인터페이스의 변수명을 이용해보자라는 생각이 들었는데요. 하나의 작업을 Consumer로 묶으면 변수명을 보고 로직의 이유를 바로 유츄가 가능하다는 점을 이용해보았습니다.

 

    public static void main(String[] args) {
        
        List<Order> orders = repository.getOrders();

        Consumer<List<Order>> 사용한쿠폰중만료된쿠폰삭제 = repository.consumeDeleteCoupon();
        Consumer<List<Order>> 금액환불 = repository.consumeRefund();
        Consumer<List<Order>> 영수증생성 = repository.consumeSaveReceipt();
        Consumer<List<Order>> 주문상태변경 = repository.consumeChangeCancelStatus();

        사용한쿠폰중만료된쿠폰삭제.andThen(
        금액환불.andThen(
        영수증생성.andThen(
        주문상태변경)))
        .accept(orders);

    }

 

물론 위에처럼 무턱대고 저렇게 하지는 않겠지만, 적절한 예시가 생각이 나지 않아 일단 이렇게 예시를 만들어봤습니다.

예를들어, 취소요청이 들어온 주문 건에 대한 비즈니스로직을 작성하려고합니다.

1. 사용된 쿠폰은 다시 사용할 수 있게 변경되어야 하고, 현재시간 기준 만료된 쿠폰은 삭제되어야한다.

2. 남은 금액은 환불되어야한다.

3. 환불건에 대해서 기록이 남아야한다.

4. 주문건은 삭제되지않고 기록을 남겨둔다.

 

라는 요청이라고 가정했을 때, 위와같이 Consumer 를 이용해서 각 기능별로 나눈다면 해당 작업에 대한 로직을 변수명을 보고 유추하기 쉽고, 작업의 순서를 파악하기도 쉬워졌습니다. 이렇게 로직을 분리해 결합도를 낮춰 사이드 이펙트에 대한 문제도 조금 해소되는것 같습니다.

 

추가로 andThen의 반환타입은 Consumer<T> 이기 때문에 이어 붙히는것이 가능합니다. 따라서 위의 코드를 더 정리한다면

    public static void main(String[] args) {
        List<Order> orders = service.getOrders();
        Consumer<List<Order>> consumer = service.getConsumeCancelOrder();
        consumer.accept(orders);
    }
    
public class Service {

    private static final Repository repository = new Repository();

    public Consumer<List<Order>> getConsumeCancelOrder() {
        Consumer<List<Order>> 사용한쿠폰중만료된쿠폰삭제 = repository.consumeDeleteCoupon();
        Consumer<List<Order>> 금액환불 = repository.consumeRefund();
        Consumer<List<Order>> 영수증생성 = repository.consumeSaveReceipt();
        Consumer<List<Order>> 주문상태변경 = repository.consumeChangeCancelStatus();

        return 사용한쿠폰중만료된쿠폰삭제.andThen(
            금액환불.andThen(
            영수증생성.andThen(
            주문상태변경)));
    }
}

accept와 Consumer 를 분리하는 것도 괜찮은것같습니다. 물론 getConsumeCancelOrder 를 호출할때마다 새로 생성할 필요가 없으므로 Spring의 Bean이나 static, Singleton 으로 관리하는 것이 옳지만 여기까지만 설명하도록 하겠습니다.

 

 

Consumer이의외 BiConsumer, IntConsumer 등등 사용법은 Consumer와 동일합니다. 다만 ObjXXXConsumer 인터페이스는 andThen 메소드가 없으니 유의바랍니다.

 

 

 

3. Function

인터페이스 메소드 설명
Function<T, R> R apply(T t) T를 받아서 R을 반환
Function<T, U, R> R apply(T t, U u) T와 U를 받아서 R을 반환
IntFunction<R> R apply(int value) int를 받아서 R을 반환
LongFunction<R> R apply(long value) long을 받아서 R을 반환
DoubleFunction<R> R apply(double value) double을 받아서 R을 반환
XXXToYYYFunction Y applyAsXXX(X value) XXX를 받아서 YYY를 반환
ToXXXFunction<T> X applyAsXXX(T t) T를 받아서 XXX를 반환
ToXXXBiFunction<T, U> X applyAsXXX(T t, U u) T와 U를 받아서 XXX를 반환

 

 

public enum Payment {

    KAKAO(i -> (int) (0.95 * i)),
    NAVER(i -> (int) (0.90 * i)),
    APPLE(i -> i),;

    private final Function<Integer, Integer> function;

    public int execute(int amount) {
        return function.apply(amount);
    }

    Payment(Function<Integer, Integer> function) {
        this.function = function;
    }
}

    public static void main(String[] args) {
        int amount = 10000;

        Payment payment = Payment.KAKAO;
        int execute = payment.execute(amount);
        System.out.println("execute = " + execute); // 9500
    }

 

제가 가장 즐겨사용하는 것이 Function 함수입니다. 특히 열거형타입과 함께 사용한다면 더욱 편해지는데요.

위의 예시 코드는 결제방식에 따라 결제금액이 할인되는(?) 말도안되는 로직이지만 이렇게 enum 타입을 사용한다면 조금더 직관적으로 사용할 수 있다는 장점이 있는것 같습니다.

 

 

 

4. Predicate

인터페이스 메소드 설명
Predicate<T> boolean test(T t) T를 받아 true/false 를 반환
BiPredicate<T, U> boolean test(T t, U u) T와 U를 받아 true/false 를 반환
IntPredicate boolean test(int value) int를 받아 true/false 를 반환
LongPredicate boolean test(long value) long을 받아 true/false 를 반환
DoublePredicate boolean test(double value) double을 받아 true/false 를 반환

 

 

    public static void main(String[] args) {

        Predicate<User> filterScore = user -> user.getScore() > 50;

        List<User> users = service.getUsers();

        for (User user : users) {
            if (filterScore.test(user)) {
                System.out.println(user.getName());
            }
        }

    }

 

Predicate는 매개변수로 들어온 데이터를 true / false로 반환하는 인터페이스입니다.

Stream을 사용할때 filter에서 이 Predicate 인터페이스를 받고 있습니다.

 

 

5. Supplier

인터페이스 메소드 설명
Supplier<T> T get() T를 반환
IntSupplier int getAsInt() int를 반환
LongSupplier long getAsLong() long을 반환
DoubleSupplier double getAsDouble() double을 반환
BooleanSupplier boolean getAsBoolean() boolean을 반환

 

Supplier도 어떻게 사용한느지 사실 조금 애매한 감이 있습니다. 데이터를 받지 않고 어떤 값을 반환한다는게 사실 감이 잡히지 않습니다.

Optional 에서 or() 이나 orElseThrow() 에서 사용하는것을 보았을때는 딱히 메리트있어보이는 함수는 아니었습니다. 그런데 조금더 찾아보니까 get()을 통해 Lazy Evaluation(지연 연산) 을 할 수 있다는 것을 알아냈습니다.

 

 

    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        printOnlyValid(1, threadSleep());
        printOnlyValid(0, threadSleep());
        printOnlyValid(-1, threadSleep());

        long end = System.currentTimeMillis();
        System.out.println("걸린시간 : " + ((end - start) / 1000) + "초");
    }

    private static String threadSleep() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "Success";
    }

    private static void printOnlyValid(int index, String data) {
        if (index > 0) {
            System.out.println(data);
        } else {
            System.out.println("Invalid");
        }
    }

printOnlyValid 메소드를 실행하기에 앞서 String data 값을 가져오기 위해 threadSleep() 메소드를 실행하는 모습입니다.

threadSleep() 메소드는 3초의 시간이 걸리기때문에 3번의 실행으로 총 9초의 시간이 걸렸습니다.

 

이 메소드를 Supplier 함수를 사용하여 바꿔보겠습니다.

 

    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        printOnlyValid(1, () -> threadSleep());
        printOnlyValid(0, () -> threadSleep());
        printOnlyValid(-1, () -> threadSleep());

        long end = System.currentTimeMillis();
        System.out.println("걸린시간 : " + ((end - start) / 1000) + "초");
    }

    private static String threadSleep() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "Success";
    }

    private static void printOnlyValid(int index, Supplier<String> data) {
        if (index > 0) {
            System.out.println(data.get());
        } else {
            System.out.println("Invalid");
        }
    }

printOnlyValid의 data 타입을 Supplier로 변경했기때문에 index > 0 일 경우에만 thradSleep() 메소드가 실행되어 3번의 메소드 실행 시간이 9초에서 3초로 줄었습니다.

 

 

결론

함수형 인터페이스는 그 종류도 많고 사용방법도 다양해 언제 어디서 사용할지 애매한 경우가 많은 것같습니다. 그래도 이 글을 작성하면서 JAVA 8 에서 함수형 인터페이스, Lambda, Stream 의 연결성에 대해서 공부가 된것같습니다.

 

공부하면서 작성한 글이기때문에 틀린부분이 있을 수 있습니다. 언제든지 지적해주시기 바랍니다.

그리디 알고리즘

그리디 알고리즘은 '탐욕법'이라고도 불리는데 여기에선 그리디 알고리즘으로 사용하겠습니다.

그리디 알고리즘은 해를 구하는 과정에서 각 단계마다 '가장 최선'의 최적해를 선택해 나가면서 결과적으로 전체적인 최적의 해를 구하려는 알고리즘입니다.

하지만 각 단계마다 최적의 해를 구한다고해서 전체의 최적해를 만들지 못할 수도 있습니다. 즉 전체적인 최적의 해를 보장하지 않는다는 말입니다.

 

예를들어, S에서 시작해서 E에 도착할때까지의 수의 합이 가장 높은 노선을 구해봅시다.

 

S에서 시작해서 E까지의 노선중에서 수의 합이 가장 높은 노선은 S - 3 - 8 - E 이고 합은 11이라는것을 바로 알 수 있을 것입니다.

하지만 그리디 알고리즘을 적용해보면

 

1, 3, 5 중에 가장 높은 5를 선택

 

 

3, 2 중에 가장 높은 3을 선택

 

 

 

결과

 

S - 5 - 3 - E 라는 결과가 나왔습니다. 각 노선을 선택할 때마다 가장 높은 수를 선택했지만 최적해인 11에 미치지 못하는 8이 되었습니다. 따라서 그리디 알고리즘은 현재에서 가장 최선의 값을 선택하지만 그것이 항상 최적의 값을 보장하지 않는 알고리즘입니다.

 

 

 

그리디 알고리즘 예제

대표적인 문제로는 거스름돈 문제가 있습니다. 거스름돈에 사용되는 동전의 개수를 최소로 하는 문제입니다.

 

    public static void main(String[] args) {

        Integer[] coins = {100, 50, 10, 500}; // 동전
        int money = 780; // 거스름돈
        int count = 0; // 동전 사용 개수

        // 사용할 동전을 액수가 큰 순서대로 정렬
        Arrays.sort(coins, Comparator.reverseOrder());

        // 차례대로 거스름돈에서 차감
        for (int coin : coins) {
            count += money / coin;
            money %= coin;
        }

        // 결과 출력
        if (money == 0) {
            System.out.println("결과 count = " + count);
        }

    }

 

Collection List

Collection중에 가장 많이 사용하는 것은 아마도 List일것입니다. List에는 여러 구현체가 있습니다.

ArrayList, LinkedList, Vector 가 있는데 오늘 알아볼것은 ArrayList와 LinkedList입니다.

 

최적화를 알아보기 전에 ArrayList와 LinkedList에 대해서 간략하게 공부하고 넘어가겠습니다.

 

 

ArrayList

  • 내부 배열에 객체를 저장해서 INDEX로 관리합니다.
  • 객체를 추가하는경우 자동적으로 저장 용량이 늘어납니다.
  • INDEX를 중간에 추가 또는 삭제할 경우 해당 INDEX 바로 뒤 부터 마지막 INDEX까지 모두 한칸 이동시키고 해당 INDEX에 객체를 추가 또는 삭제시킵니다.

 

 

 

LinkedList

  • 각 객체마다 노드를 연결하여 관리합니다.
  • 객체를 추가 또는 삭제할 때 노드의 연결위치를 변경해 추가 또는 삭제합니다.

 

 

 

 

InitialCapacity

List를 많이 다루어봤어도 아마 InitialCapacity는 생소할 수도 있습니다.

ArrayList의 생성자

보통 기본생성자나 리스트를 copy할 목적으로 사용했지만 사실 ArrayList에는 initialCapacity라는 값도 받고있었습니다.

 

위에 ArrayList의 특징에 객체를 추가하는경우 자동적으로 저장 용량이 늘어납니다. 라고 적어놓았었는데 여기에는 initialCapacity가 관여합니다.

 

    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA; // = {};
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }

 

initialCapacity를 설정한다는 것은 ArrayList의 배열의 길이를 설정한다는 것과 같습니다.

다르게말하면 객체를 추가할 때 저장용량을 늘리는 작업을 하지 않게되면 속도가 더 빨라진다는 말이죠.

add 할 때 capacity를 초과하면  용량 확장

initialCapacity의 기본값은 10입니다. ArrayList의 길이가 10이 넘어가면 저장용량을 확장하게되는데, List에 저장할 데이터의 수를 이미 알고있다면 initialCapacity를 설정하는것만으로 속도를 향상 시킬 수 있습니다.

 

 

 

속도 테스트 전 테스트 환경

    private static int LOOP = 200;

    public static void speed(String methodTitle, CallbackMethod callback) {

        int avg = 0;
        for (int i = 0; i < LOOP; i++) {

            long startTime = System.currentTimeMillis();

            callback.execute();

            long endTime = System.currentTimeMillis();

            int totalTime= (int) (endTime - startTime);

            avg += totalTime;
        }

        avg /= LOOP;

        System.out.printf("%s Method Average Speed : %dms \n", methodTitle, avg);
    }

 

평균값을 얻기위해 200번 callback (테스트 메서드가 들어올 callback함수) 메서드를 실행해서 최종 평균값을 확인해보겠습니다.

속도비교

private static final int LIST_SIZE = 1000000; // 1_000_000

    public static void main(String[] args) {

        OptimalTest.speed("ArrayList", ListTest::arrayListTest);
        OptimalTest.speed("InitialCapacity ArrayList", ListTest::initialCapacityTest);

//        ArrayList Method Average Speed : 60ms
//        InitialCapacity ArrayList Method Average Speed : 46ms
//        속도 차이 : 14ms
    }
    
    private static void arrayListTest() {
        List<String> test = new ArrayList<>();
        for (int i = 0; i < LIST_SIZE; i++) {
            test.add(String.valueOf(i));
        }
    }
    private static void initialCapacityTest() {
        List<String> test = new ArrayList<>(LIST_SIZE);
        for (int i = 0; i < LIST_SIZE; i++) {
            test.add(String.valueOf(i));
        }
    }

 

실제로 capacity 값을 미리 설정해준다면 유의미한 속도향상을 확인할 수 있었습니다.

 

ArrayList vs LinkedList

어느정도 예상하시겠지만 ArrayList와 LinkedList의 속도도 비교해보았습니다.

 

    private static final int LIST_SIZE = 1000000; // 1_000_000

    public static void main(String[] args) {
        OptimalTest.speed("ArrayList", ListTest::arrayListTest);
        OptimalTest.speed("InitialCapacity ArrayList", ListTest::initialCapacityTest);
        OptimalTest.speed("LinkedList", ListTest::linkedListTest);
//        ArrayList Method Average Speed : 63ms 
//        InitialCapacity ArrayList Method Average Speed : 47ms
//        LinkedList Method, Speed : 42ms
//        속도 차이 : 22ms
    }

    private static void arrayListTest() {
        ArrayList<String> test = new ArrayList<>();
        for (int i = 0; i < LIST_SIZE; i++) {
            test.add(String.valueOf(i));
        }
    }
    private static void initialCapacityTest() {
        ArrayList<String> test = new ArrayList<>(LIST_SIZE);
        for (int i = 0; i < LIST_SIZE; i++) {
            test.add(String.valueOf(i));
        }
    }
    private static void linkedListTest() {
        LinkedList<String> test = new LinkedList<>();
        for (int i = 0; i < LIST_SIZE; i++) {
            test.add(String.valueOf(i));
        }
    }

 

 

저장용량을 늘릴 필요가 없는 LinkedList가 가장 빨랐고,

저장용량을 설정한 ArrayList가 두번째,

저장용량을 설정하지 않은 ArrayList가 가장 느렸습니다.

 

 

 

Remove

    public static void main(String[] args) {
        OptimalTest.speed("ArrayList", () -> arrayListRemove(arraylist));
        OptimalTest.speed("LinkedList", () -> linkedListRemove(linkedlist));
//        ArrayList Remove Method Average Speed : 146ms
//        LinkedList Remove Method Average Speed : 0ms
    }
    
    private static void arrayListRemove(List<String> arraylist) {
        for (int i = 0; i < 1000; i++) {
             arraylist.remove(0);
        }
    }

    private static void linkedListRemove(List<String> linkedlist) {
        for (int i = 0; i < 1000; i++) {
            linkedlist.remove(0);
        }
    }

200번의 테스트때문에 1000번씩 0번 INDEX를 Remove 한 결과입니다.

 

노드의 경로만 변경해주면되는 LinkedList와는 다르게 ArrayList는 0번 INDEX를 제외한 나머지 배열을 한칸 앞으로 이동해야하기 때문에 속도 차이가 많이 벌어진것입니다.

 

Add

    public static void main(String[] args) {
        OptimalTest.speed("ArrayList Add", () -> arrayListRemove(arraylist));
        OptimalTest.speed("LinkedList Add", () -> linkedListRemove(linkedlist));
//        ArrayList Add Method Average Speed : 277ms 
//        LinkedList Add Method Average Speed : 0ms 
    }
    
    private static void arrayListRemove(ArrayList<String> arraylist) {
        for (int i = 0; i < 1000; i++) {
             arraylist.add(0, String.valueOf(i));
        }
    }

    private static void linkedListRemove(LinkedList<String> linkedlist) {
        for (int i = 0; i < 1000; i++) {
            linkedlist.add(0, String.valueOf(i));
        }
    }

 

0번 INDEX에 Add도 마찬가지였습니다.

여기서 중요한건 가장 뒤에 add하는 것은 LinkedList와 ArrayList와의 차이가 나지않을정도로 작습니다.

 

 

모든 결과를 보았을 때 전체적으로 LinkedList가 훨씬 좋아보입니다. 그렇다면 이제 ArrayList를 사용하지 않고 LinkedList만 사용해야할 까요?

    public static void main(String[] args) {
        OptimalTest.speed("ArrayList Get", () -> arrayListRemove(arraylist));
        OptimalTest.speed("LinkedList Get", () -> linkedListRemove(linkedlist));
//        ArrayList Get Method Average Speed : 0ms 
//        LinkedList Get Method Average Speed : 712ms 
    }
    
    private static void arrayListGet(ArrayList<String> arraylist) {
        for (int i = 0; i < LIST_SIZE; i++) {
            arraylist.get(LIST_SIZE / 2);
        }
    }

    private static void linkedListGet(LinkedList<String> linkedlist) {
        for (int i = 0; i < 100; i++) {
            linkedlist.get(LIST_SIZE / 2);
        }
    }

 

INDEX로 값을 가져오는부분에서는 ArrayList가 압도적으로 빠릅니다. 그 이유는 ArrayList는 배열의 INDEX로 값을 가져오기 때문에 O(1)의 속도로 바로 값을 가져오지만 LinkedList는 head부터 해당 index가 나올때까지 값을 검색하기 때문에 최대 O(n)의 속도를 가지기 때문입니다.

실제로 i를 100만번 수행시켰지만 도저히 끝날 기미가 보이지 않아 linkedListGet 메서드의 i를 100으로 설정시켜서 테스트했습니다.

ArrayList는 100만번 수행시켜도 0ms인 값이 변하지 않았습니다.

 

 

결론

  • ArrayList를 사용할 때, List에 들어갈 데이터의 수를 알고있다면 initialCapacity를 설정하자
  • 데이터의 수의 대략적인 수를 알고있다면 initialCapacity를 넉넉하게 설정해도 좋다. 정확하게 설정하는것보다는 느리지만 기본생성자로 설정하는것보다 유의미한 속도향상이 있었다. ( LIST_SIZE = 100만일때,  LIST_SIZE + LIST_SIZE 의 크기로 설정해도 5ms 내외의 차이정도밖에 확인되지 않았다)
  • INDEX를 자주사용하는 메서드에서는 무조건 ArrayList를 사용하자.
  • List 내에서 추가, 삭제가 빈번히 발생한다면 LinkedList를 사용하자.

 

 

# 혼자 공부하고 작성한 글이기 때문에 정확하지 않을 수 있습니다. 대략적인 가이드? 같은것이기 때문에 참고용도로만 사용해주세요.

직렬화와 역직렬화

  • 직렬화(Serialization) : 컴퓨터 과학의 데이터 스토리지 문맥에서 데이터 구조오브젝트 상태를 동일하거나 다른 컴퓨터 환경에 저장하고 나중에 재구성할 수 있는 포맷으로 변환하는 과정
  • 역직렬화(Deserialization) : 반대로 일련의 바이트로부터 데이터 구조를 추출하는 과정

 

 

직렬화를 사용하는 이유

자바에서 원시타입 bytem short, int, long, float, double, boolean, char가 있습니다. 이를 제외한 객체들은 모두 주소값을 참조하는 참조타입입니다.

 

원시타입은 Stack영역에 값을 갖고있어 직접 접근이 가능하지만. 참조타입은 Heap에 존재하고 메모리 주소를 참조하고 있어 프로그램이 종료되거나 가비지컬렉터에 의해 Heap 영역에서 데이터가 제거된다면 데이터도 함께 사라집니다. 따라서 이 주소값을 외부에 전송했다고 하더라도 실제 메모리 주소에는 객체가 존재하지 않을 수 있다는것이죠.

 

따라서 참조타입의 객체들을 원시값 형식으로 데이터를 변환하는 과정을 거쳐 전달해야만 합니다.

 

 

직렬화의 형식

  형식 특징
텍스트 기반 형식 CSV, JSON, XML 사람이 읽을수 있는 형태
저장 공간의 효율성이 떨어지고, 파싱하는데 오래걸림
데이터의 양이 적을 때 주로 사용
최근에는 JSON형태로 직렬화를 많이함
모든 시스템에서 사용 가능
이진(Binary) 형식 CBOR, BSON, LEB128, MessagePack, Pickle, Protocol Buffers 사람이 읽을 수 없는 형태
저장 공간의 효율성이 좋고, 파싱이 빠름
데이터의 양이 많을 때 주로 사용
모든 시스템에서 사용 가능
자바 직렬화   Java 시스템 간의 데이터 교환이 필요할 때 사용

 

자바에서의 직렬화와 역직렬화

 

1. Serializable 인터페이스 구현

public class Person implements Serializable {

    private String name;
    private int age;

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

}

 

 

2. 직렬화 구현

public static void main(String[] args) {

        Person person = new Person("김OO", 20);

        byte[] serialized;

        try {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(baos);
            oos.writeObject(person);

            // serialized -> 직렬화된 Person 객체
            serialized = baos.toByteArray();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        // 바이트 배열로 생성된 직렬화 데이터를 base64로 변환
        System.out.println(Base64.getEncoder().encodeToString(serialized));
        
        // 출력 결과
        // rO0ABXNyAB/sp4HroKztmZTsmYDsl63sp4HroKztmZQuUGVyc29uc1NTeAAdcbkCAAJJAANhZ2VMAARuYW1ldAASTGphdmEvbGFuZy9TdHJpbmc7eHAAAAAUdAAF6rmAT08=

    }

 

 

 

3. 역직렬화 구현

역직렬화의 조건은 자바 직렬화 대상 객체가 동일한 serialVersionID를 가지고 있어야합니다.

 

    public static void main(String[] args) {

        byte[] serialized = { -84, -19, 0, 5, 115, 114, 0, 31, -20, -89, ... 생략};

        try {
            ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
            ObjectInputStream ois = new ObjectInputStream(bais);
            
            // 역직렬화된 Person 객체를 가져온다
            Person newObject = (Person) ois.readObject();
            System.out.println("newObject = " + newObject);
            // 출력결과
            // newObject = Person { name = '김OO', age = 20 }
        } catch (IOException | ClassNotFoundException e) {
            throw new RuntimeException(e);
        }

    }

 

 

만약 특정 필드의 값을 직렬화하고 싶지 않다면 transient 키워드를 붙히면됩니다.

public class Person implements Serializable {

    private transient String name;
    private int age;

	// 이하 생략
}

// 해당객체의 역직렬화 결과
// Person { name = null, age = 20 }

 

 

 

직렬화의 주의점

 

1. 객체의 멤버변수가 추가되었을 때

// Caused by: java.io.InvalidClassException: Person; local class incompatible: stream classdesc serialVersionUID = 8310077512291807673, local class serialVersionUID = 2986034573208750977

InvalidClassException 예외가 발생합니다. serialVersionUID가 일치하지 않는다는 뜻입니다.

멤버변수의 추가로인해 serialVersionUID가 새로운 값을 가지면서 이전 serialVersionUID 값과 일치하지 않아 생긴 예외입니다.

 

public class Person implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;
    private String name;
    private int age;

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

    @Override
    public String toString() {
        return String.format("Person { name = '%s', age = %d }", name, age);
    }
}

 

serialVersionUID를 명시해주면 멤버 변수를 추가해도 에러가 발생하지 않습니다. 그리고 기존에 있던 멤버변수를 삭제해도 에러가 발생하지 않습니다.

 

 

2. 객체의 멤버변수의 타입이 변경되었을 때

public class Person implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;
    private String name;
    private long age;

    public Person(String name, long age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return String.format("Person { name = '%s', age = %d }", name, age);
    }
}

// java.io.InvalidClassException: Person; incompatible types for field age

age의 타입을 int -> long 으로 변경한다면 InvalidClassException 이 발생합니다.

 

 

결론

자바 직렬화는 장점이 많지만 단점도 많습니다. 이 단점을 보완하기 힘든 형태로 되어있기 때문에 제약이 많아 사용시에 주의가 필요합니다.

 

  1. 오랫동안 외부에 저장하는 의미 있는 데이터는 직렬화하지 않는다.
  2. 역직렬화시 반드시 예외가 생긴다는것을 염두한다.
  3. 자주 변경되는 비즈니스적인 데이터는 자바 직렬화를 사용하지 않는다.
  4. 긴 만료시간을 가지는 데이터는 JSON 등 다른 포맷을 사용하여 저장한다.

 

 

참고

+ Recent posts