Develop/JAVA

[디버깅 기록] Java DNS Cache TTL 무시되는 이슈 | [Debugging Log] Java DNS Cache TTL Ignored Issue

java main 에서 dns cache 를 끄는법을 찾아보면 아래와 같이 main() 안에서 이런 한 줄로 DNS cache 를 끄는 예제 코드가 실제로 많이 나온다.public static void main(String[] args) { Security.setProperty("sun.net.inetaddr.ttl", "0"); Security.setProperty("networkaddress.cache.ttl", "0");} 이렇게 되어있어서 당연히 main 에 설정한대로 DNS Cache 가 적용되지 않고있다고 (ttl = 0) 생각했는데만약 java process를 OTEL java agent 가 붙거나 jmx 설정과 함께 사용하고 있었다면, 위 main 에 설정한 값들이 무시된..

[디버깅 기록] Java DNS Cache TTL 무시되는 이슈 | [Debugging Log] Java DNS Cache TTL Ignored Issue

728x90

java main 에서 dns cache 를 끄는법을 찾아보면 아래와 같이 main() 안에서 이런 한 줄로 DNS cache 를 끄는 예제 코드가 실제로 많이 나온다.

public static void main(String[] args) {
      Security.setProperty("sun.net.inetaddr.ttl", "0");
      Security.setProperty("networkaddress.cache.ttl", "0");
}

 

이렇게 되어있어서 당연히 main 에 설정한대로 DNS Cache 가 적용되지 않고있다고 (ttl = 0) 생각했는데

만약 java process를 OTEL java agent 가 붙거나 jmx 설정과 함께 사용하고 있었다면, 위 main 에 설정한 값들이 무시된다.

export JAVA_TOOL_OPTIONS="-javaagent:/app/lib/opentelemetry-javaagent.jar"

java \
    -server -Xms4G -Xmx4G -XX:+UseG1GC \
    -Dcom.sun.management.jmxremote \
    -Dcom.sun.management.jmxremote.port=29010 \
    -Djava.rmi.server.hostname=$(hostname -f) \
    -jar /app/lib/my-service.jar

즉, ttl = 0 이 설정이 먹게하는 가장 간단한 방법은 JVM 시작 옵션에 넣는 방법이다.

java -Dsun.net.inetaddr.ttl=0 -jar /app/lib/my-service.jar

다만 sun.net.inetaddr.ttl=0 은 deprecated 예정인 설정이므로 networkaddress.cache.ttl=0 을 사용해야하는데, 이 설정은 보안속성 파일에 넣고, -D 런타임 인자로 보안속성 파일을 지정해야한다.

java -Dsun.net.inetaddr.ttl=0 -Djava.security.properties=/path/to/custom.security -jar /app/lib/my-service.jar

 

그래서 나는 이렇게 해결했다.

 

디버깅 과정

위 결과만 보면 참 간단하지만, 디버깅 과정은 험란했는데 다 요약하면 

reflection 으로 `sun.net.InetAddressCachePolicy.get()` 의 실제 값을 찍어봤다.
이 메서드는 JVM 내부의 진짜 cache TTL 정적 필드를 그대로 반환한다.

public static void main(String[] args) throws Exception {
    boolean shouldSet = args.length > 0 && "setProperty".equals(args[0]);

    if (shouldSet) {
        Security.setProperty("networkaddress.cache.ttl", "0");
        Security.setProperty("networkaddress.cache.negative.ttl", "0");
    }

    Class<?> c = Class.forName("sun.net.InetAddressCachePolicy");
    int positive = (int) c.getDeclaredMethod("get").invoke(null);
    int negative = (int) c.getDeclaredMethod("getNegative").invoke(null);
    System.out.println("Security networkaddress.cache.ttl        = "
            + Security.getProperty("networkaddress.cache.ttl"));
    System.out.println("InetAddressCachePolicy.get()  (positive) = " + positive);
    System.out.println("InetAddressCachePolicy.getNegative()     = " + negative);
}

 

따라서 위의 베이스 코드를 기반으로 setProperty를 main 으로 하고 OTEL agent runtime injection 여부에 따라 디버깅 코드를 실행시켜보면 아래와 같은 결과가 나오게 된다.

######## Case A: setProperty in main, no agent ########

Security networkaddress.cache.ttl        = 0
InetAddressCachePolicy.get()  (positive) = 0
InetAddressCachePolicy.getNegative()     = 0



######## Case B: setProperty in main + OTEL agent ########

Security networkaddress.cache.ttl        = 0
InetAddressCachePolicy.get()  (positive) = 30   ← ???
InetAddressCachePolicy.getNegative()     = 10

Case B 에서 SetProperty() 로 분명 TTL을 0으로 줬는데 적용되지 않았다. Security.getProperty("networkaddress.cache.ttl")  0을 반환하는데, JVM 내부의 실제 cache policy 는 30으로 박혀있다. 즉, main 에서 setProperty() 호출을 했으나 실제 DNS resolve 시점에는 이 값이 전혀 안쓰이게 된다.

 

왜그럴까? 이유

JDK 17 소스 의 sun.net.InetAddressCachePolicy 일부 발췌

public static final int DEFAULT_POSITIVE = 30;
private static volatile int cachePolicy = FOREVER;


static {
    Integer tmp = AccessController.doPrivileged(new PrivilegedAction<Integer>() {
        public Integer run() {
            try {
                String tmpString = Security.getProperty("networkaddress.cache.ttl");
                if (tmpString != null) return Integer.valueOf(tmpString);
            } catch (NumberFormatException ignored) {}
            
            try {
                String tmpString = System.getProperty("sun.net.inetaddr.ttl");
                if (tmpString != null) return Integer.decode(tmpString);
            } catch (NumberFormatException ignored) {}
            return null;
        }
    });

    if (tmp != null) {
        cachePolicy = tmp < 0 ? FOREVER : tmp;
    } else if (System.getSecurityManager() == null) {
        cachePolicy = DEFAULT_POSITIVE;   // 둘 다 null 이면 30 박힘
    }
}

public static int get() {
    return cachePolicy;

}

 

static {} 블록은 clinit(class initializer)에서 클래스가 처음 로드될 때 딱 한 번만 실행된다.
그때
Security.getProperty("networkaddress.cache.ttl")과 System.getProperty("sun.net.inetaddr.ttl" 둘 다 읽어보고 cachePolicy 정적 필드에 값을 넣어버린다. 그리고 이후 InetAddressCachePolicy에서 get() 을 할 때는 cachePolicy를 리턴해 버린다.

이런 상황에서 main 에서 Security.setProperty(...) 를 호출해봤자, 그건 Security 모듈의 properties맵을 업데이트하는 거지 cachePolicy 정적 필드를 갱신하는 게 아니다. clinit(정적 초기화 메서드) 이 이미 끝났으면 그냥 안 먹는다.

위에서 테스트한 CaseB가 발생해서 디버깅을 하게된 점은 clinit 이 main() 보다 먼저 실행될 수 있다는 점을 간과했다. OTEL과 같은 javaagent 같은 게 -javaagent: 로 붙어있으면 premain 단계에서 동작하고, 이 premain 단계에서 hostname 을 한 번이라도 resolve 하면 이 시점에 InetAddressCachePolicy 가 트리거된다. 그 시점엔 main() 의 Security.setProperty가 아직 실행 안 됐으니 Security.getProperty 가 null 을 반환하고, fallback 인 System.getProperty("sun.net.inetaddr.ttl") 도 null이었기에 default 30 이 박혀버린다.

여기서 말하는 premain 단계라는 것은 java agent 가 main() 보다 먼저 실행되는 진입점을 의미하더라. APM 등등 각종 라이브러리 jar 안에는 보통 클래스에 아래와 같은 premain() 메서드가 있다고하더라

public static void premain(String args, Instrumentation inst) {
      // 여기 코드가 너의 main() 보다 먼저 실행됨
}

실제로 otel-agent.jar 를 까보라고 해보니 premain 코드호출체인 속에 InetAddress.getLocalHost()를 호출하면서 InetAddressCachePolicy를 호출하게 되는 부분이 있었다.

  premain(agentArgs, inst)
    └─ startAgent(inst, true)
         └─ AgentInitializer.initialize(...)
              └─ ... (SDK/resource 초기화)
                   └─ HostResource.buildResource()
                        └─ InetAddress.getLocalHost()   ← <clinit> 트리거

 

따라서 -Dsun.net.inetaddr.ttl=0 이라도 JVM 시작시점에 넣어주거나, -Djava.security.properties=/path/to/custom.security 로 networkaddress.cache.ttl=0을 지정한 보안설정파일을 넣어주지 않는다면 default 30 이 되버리는 것이다. 이때문에 CaseB 에서 Security.getProperty() 로 읽어왔을땐 main 에서 세팅한 0이 읽혔으나, InetAddressCachePolicy를 통해서 읽었을때는 아니게 된 것이다.

사실 OTEL 만의 문제는 아니고, premain 단계에서 InetAddress 를 한 번이라도 건드리는 모든 컴포넌트가 trigger 가 될 수 있다. 예를 들면 -Dcom.sun.management.jmxremote (JMX 관리 에이전트) 나 -agentlib:jdwp= (디버거) 같은 옵션도 hostname resolve 를 하니까 같은 효과가 난다. 운영 환경에서는 보통 JMX 는 켜져 있을 거고, 디버그 빌드면 JDWP 도 켜져 있다. 결국 main() 보다 먼저 InetAddressCachePolicy 가 init 되는 경우는 생각보다 흔하다.

 

결론

premain() 단계에서 host resolving을 하는 컴포넌트가 트리거 되는 경우, Dns Cache TTL 설정을 main() 함수에서 한다면 DnsCache의 static 변수 캐싱으로 인해 내가 세팅한 값이 안먹을 수 있다.

옵션 한 줄 빠뜨려서 운영 사고로 직결되는 게 쉽지 않았고. 당연히 main()에 ttl 설정을 해두었으니 동작할거라고 생각한 부분이 패인이었다. 동료분과 함께 디버그 하지 않았다면 알기 어려웠다. 또한 DNS 변경이 일어나는 운영상 경험(domain 변경 작업)이 있을때 발견가능한 버그였어서 작업 진행시 오류도 많았다.

쉽지않다 코딩

If you look up how to disable the DNS cache in a Java main, you'll find plenty of example code that turns off the DNS cache with a single line inside main(), like this:

public static void main(String[] args) {
      Security.setProperty("sun.net.inetaddr.ttl", "0");
      Security.setProperty("networkaddress.cache.ttl", "0");
}

 

Since that's how it was set up, I naturally assumed the DNS cache was disabled exactly as configured in main (ttl = 0).

But if your Java process was running with an OTEL Java agent attached or together with a JMX configuration, the values you set in main above are ignored.

export JAVA_TOOL_OPTIONS="-javaagent:/app/lib/opentelemetry-javaagent.jar"

java \
    -server -Xms4G -Xmx4G -XX:+UseG1GC \
    -Dcom.sun.management.jmxremote \
    -Dcom.sun.management.jmxremote.port=29010 \
    -Djava.rmi.server.hostname=$(hostname -f) \
    -jar /app/lib/my-service.jar

In other words, the simplest way to make ttl = 0 actually take effect is to pass it as a JVM startup option.

java -Dsun.net.inetaddr.ttl=0 -jar /app/lib/my-service.jar

That said, sun.net.inetaddr.ttl=0 is slated to be deprecated, so you should use networkaddress.cache.ttl=0 instead. This one has to go into a security properties file, and you specify that security properties file via a -D runtime argument.

java -Dsun.net.inetaddr.ttl=0 -Djava.security.properties=/path/to/custom.security -jar /app/lib/my-service.jar

 

So that's how I solved it.

 

The Debugging Process

Looking at just the result above, it seems pretty simple, but the debugging process was rough. To sum it all up: 

I used reflection to print out the actual value of `sun.net.InetAddressCachePolicy.get()`.
This method returns the real cache TTL static field inside the JVM as-is.

public static void main(String[] args) throws Exception {
    boolean shouldSet = args.length > 0 && "setProperty".equals(args[0]);

    if (shouldSet) {
        Security.setProperty("networkaddress.cache.ttl", "0");
        Security.setProperty("networkaddress.cache.negative.ttl", "0");
    }

    Class<?> c = Class.forName("sun.net.InetAddressCachePolicy");
    int positive = (int) c.getDeclaredMethod("get").invoke(null);
    int negative = (int) c.getDeclaredMethod("getNegative").invoke(null);
    System.out.println("Security networkaddress.cache.ttl        = "
            + Security.getProperty("networkaddress.cache.ttl"));
    System.out.println("InetAddressCachePolicy.get()  (positive) = " + positive);
    System.out.println("InetAddressCachePolicy.getNegative()     = " + negative);
}

 

So, based on the base code above, if I call setProperty in main and run the debugging code depending on whether the OTEL agent runtime injection is present, I get results like the following.

######## Case A: setProperty in main, no agent ########

Security networkaddress.cache.ttl        = 0
InetAddressCachePolicy.get()  (positive) = 0
InetAddressCachePolicy.getNegative()     = 0



######## Case B: setProperty in main + OTEL agent ########

Security networkaddress.cache.ttl        = 0
InetAddressCachePolicy.get()  (positive) = 30   ← ???
InetAddressCachePolicy.getNegative()     = 10

In Case B, I clearly set the TTL to 0 with setProperty(), but it didn't take effect. Security.getProperty("networkaddress.cache.ttl") returns 0, but the actual cache policy inside the JVM is stuck at 30. In other words, even though setProperty() was called in main, this value isn't used at all at the point of the actual DNS resolve.

 

Why Does This Happen? The Reason

An excerpt from sun.net.InetAddressCachePolicy in the JDK 17 source

public static final int DEFAULT_POSITIVE = 30;
private static volatile int cachePolicy = FOREVER;


static {
    Integer tmp = AccessController.doPrivileged(new PrivilegedAction<Integer>() {
        public Integer run() {
            try {
                String tmpString = Security.getProperty("networkaddress.cache.ttl");
                if (tmpString != null) return Integer.valueOf(tmpString);
            } catch (NumberFormatException ignored) {}
            
            try {
                String tmpString = System.getProperty("sun.net.inetaddr.ttl");
                if (tmpString != null) return Integer.decode(tmpString);
            } catch (NumberFormatException ignored) {}
            return null;
        }
    });

    if (tmp != null) {
        cachePolicy = tmp < 0 ? FOREVER : tmp;
    } else if (System.getSecurityManager() == null) {
        cachePolicy = DEFAULT_POSITIVE;   // if both are null, it gets stuck at 30
    }
}

public static int get() {
    return cachePolicy;

}

 

The static {} block runs in clinit (the class initializer) exactly once, when the class is first loaded.
At that moment it reads both
Security.getProperty("networkaddress.cache.ttl") and System.getProperty("sun.net.inetaddr.ttl", and assigns a value to the cachePolicy static field. After that, whenever get() is called on InetAddressCachePolicy, it just returns cachePolicy.

In this situation, calling Security.setProperty(...) in main does nothing useful — that updates the properties map of the Security module, not the cachePolicy static field. If clinit (the static initializer method) has already finished, it simply won't take effect.

What led me to debug this — because Case B above happened — was that I overlooked the fact that clinit can run before main(). When a javaagent like OTEL is attached via -javaagent:, it operates in the premain phase, and if it resolves the hostname even once during that premain phase, that's when InetAddressCachePolicy gets triggered. At that point main()'s Security.setPropertyhasn't run yet, so Security.getProperty returns null, and since the fallback System.getProperty("sun.net.inetaddr.ttl") was also null, the default 30 gets stuck in there.

The premain phase I'm talking about here refers to the entry point where the java agent runs before main(). Apparently, inside various library jars like APMs, a class usually has a premain() method like the one below.

public static void premain(String args, Instrumentation inst) {
      // the code here runs before your main()
}

When I actually had someone crack open otel-agent.jar, there was indeed a part in the premain call chain that calls InetAddress.getLocalHost(), which in turn ends up calling InetAddressCachePolicy.

  premain(agentArgs, inst)
    └─ startAgent(inst, true)
         └─ AgentInitializer.initialize(...)
              └─ ... (SDK/resource 초기화)
                   └─ HostResource.buildResource()
                        └─ InetAddress.getLocalHost()   ← <clinit> 트리거

 

So unless you pass -Dsun.net.inetaddr.ttl=0 at JVM startup, or supply a security properties file with networkaddress.cache.ttl=0 set via -Djava.security.properties=/path/to/custom.security, it ends up defaulting to 30. This is why, in Case B, when reading via Security.getProperty() it returned the 0 that was set in main, but when reading through InetAddressCachePolicy it didn't.

This actually isn't an OTEL-only problem — any component that touches InetAddress even once during the premain phase can be a trigger. For example, options like -Dcom.sun.management.jmxremote (the JMX management agent) or -agentlib:jdwp= (the debugger) also resolve the hostname, so they produce the same effect. In a production environment, JMX is usually on, and for a debug build JDWP is on too. In the end, InetAddressCachePolicy getting initialized before main() is more common than you'd think.

 

Conclusion

If a component that does host resolving during the premain() phase gets triggered, and you set the DNS Cache TTL in the main() function, the value you set may not take effect due to the static variable caching in DnsCache.

It wasn't easy that missing a single option could lead directly to a production incident. The thing that did me in was assuming it would obviously work just because I'd set the ttl in main(). If I hadn't debugged it together with a colleague, it would have been hard to figure out. Also, it was a bug you could only discover when there was an operational DNS change (a domain migration task), so there were plenty of errors while doing that work.

Coding ain't easy.

The end

댓글

Comments

Develop/JAVA

volatile을 사용한 쓰레드간 통신 동기화 | Thread Synchronization for Inter-Thread Communication Using volatile

이펙티브 자바를 읽으면서 짜릿한 단일검사(racy single-check)에 대해 찾아보던 중, 알게된 내용이다. 동기화의 기능자바의 쓰레드 프로그래밍을 해보았다면 synchronized 키워드를 몇번 접해볼 수 있을 것이다. 동기화에서 synchronized 를 이용해 한가지 자원을 동시에 접근할 때 thread safe하게 자원의 내용을 변경할 수 있어, 이 기능만 동기화의 기능이라고 보기 쉽다. 즉 synchronized 가 걸려있는 블록 혹은 메서드에서 한번에 한 쓰레드씩 수행하도록 한다.그러나 사실 동기화의 기능은 총 2가지이다. a. 배타적 실행위에 말한 대로 한 쓰레드가 변경하는 중이라서, 상태가 일관되지 않는 (공유하는 자원의) 객체를 현재 사용중인 쓰레드만 접근이 가능하고, 다른 쓰레드가..

volatile을 사용한 쓰레드간 통신 동기화 | Thread Synchronization for Inter-Thread Communication Using volatile

728x90

이펙티브 자바를 읽으면서 짜릿한 단일검사(racy single-check)에 대해 찾아보던 중, 알게된 내용이다.

 

동기화의 기능

자바의 쓰레드 프로그래밍을 해보았다면 synchronized 키워드를 몇번 접해볼 수 있을 것이다. 동기화에서 synchronized 를 이용해 한가지 자원을 동시에 접근할 때 thread safe하게 자원의 내용을 변경할 수 있어, 이 기능만 동기화의 기능이라고 보기 쉽다. 즉 synchronized 가 걸려있는 블록 혹은 메서드에서 한번에 한 쓰레드씩 수행하도록 한다.

그러나 사실 동기화의 기능은 총 2가지이다.

 

a. 배타적 실행

위에 말한 대로 한 쓰레드가 변경하는 중이라서, 상태가 일관되지 않는 (공유하는 자원의) 객체를 현재 사용중인 쓰레드만 접근이 가능하고, 다른 쓰레드가 보지 못하게 막는 용도를 말한다.

이때 락에 대한 개념이 나온다. 락을 건 메서드에서 객체의 상태를 확인하고 필요하면 수정하도록 작성했을 때, 한 쓰레드에서 해당 메서드를 사용하게 되면 객체에 락이 걸리게 되고, 해당 객체는 다른 쓰레드에서 동시에 접근이 불가능하다.

즉 배타적 실행은 객체를 하나의 일관된 상태에서 다른 일관된 상태로 변화시키는 것이다.

 

b. 쓰레드 사이의 안정적 통신

나는 이 a번만 이전에 알고있었는데, 동기화의 중요한 기능이 하나 더 있다.

동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수 있다.
동기화덕분에 한 스레드에서 락의 보호하에 수행된 수정사항을 다른 쓰레드에서 최종 결과를 볼 수 있다.

자바 언어에서 long과 double을 제외한 변수를 읽고 쓰는 동작은 원자적이다. 여러 쓰레드가 primitive 타입의 변수를 동기화 없이 수정하더라도, 각 스레드에서는 정상적으로 그 값을 온전하게 (연산중간에 끼어들지 않고 온전히) 읽어온다

 

원자적 연산

위에서 읽고 쓰는 동작이 원자적이라 했는데, 원자적 연산은 중단이 불가능한 연산을 이야기한다
여러 자바의 연산은 바이트코드로 이루어져 있는데, 하나의 연산을 수행하기 위해  바이트코드가 수행될 때 중간에 다른 쓰레드가 끼어들어서 연산의 결과가 올바르지 않게 변한다면 해당 연산은 원자적 연산이 아니다.

원자적이지 않은 동작의 예시로는 a++(증가 연산자)이 있다. cleancode책의 동시성 부록에서는 아래와 같은 설명이 나온다

lastId값이 42였다고 가정하자. 다음은 getNextId 메서드의 바이트 코드다.
예를 들어 첫째 스레드가 ALOAD 0, DUP, GETFIELD lastId까지 실행한 후 중단 되었다고 가정하자.
둘째 스레드가 끼어들어 모든 명령을 실행했다. 즉, lastId를 하나 증가해 43을 얻어갔다.
이제 첫째 스레드가 중단했던 실행을 재개한다.
첫째 스레드가 GETFIELD lastId를 실행한 당시 lastId 값은 42였다. 그래서 피연산자 스택에도 42가 들어있었다. 여기에 1을 더해 43을 얻은 후 결과를 저장한다.
첫째 스레드가 반환하는 값 역시 43이다. 둘째 스레드가 증가한 값은 잃어 버린다.
둘째 쓰레드가 첫째 스레드를 중단한 후 다시 실행된 첫째 스레드가 둘째스레드의 작업을 덮어썼기 때문이다.

즉, 여기서의 문제는 연산을 수행할 때 JVM에서 사용하는 프레임, 지연변수, 피연산자 스택에 저장하는 과정에서 원자적 연산이 아닌경우, 연산 중간 과정이 덮어씌워져 올바르지 않은 값을 갖는다는 것이다.

좀더 쉽게는 두개의 쓰레드에서 ++ 연산을 했으니 +2가 되어야하는데, ++ 연산이 원자적이지 않아 +1만 되었다는 것이다.

 

원자적 데이터에서의 동기화

위의 원자성에 대한 이야기를 들으면 원자적 데이터를 읽고 쓸 때는 (할당 연산은 원자적이다) 동기화를 하지 않아도 괜찮다고 생각 할 수 있다. (중단이 불가능하기 때문에!)

하지만 원자적 데이터라도 동기화가 필요하다

Java언어에서 스레드가 (원자적 데이터 값을 가지더라도) 필드를 읽을 때 '수정이 완전히 반영된' 값을 얻는다고 보장하지 않는다.
즉 A 쓰레드에서 필드를 수정했더라도, B 쓰레드에서 수정된 필드를 반드시 볼 수 있는 것은 아니라는 것이다.

따라서 한 쓰레드에서 수정이된 필드값을 다른 쓰레드에서 '잘 읽기' 위해서라도 동기화의 안정적인 통신이 필요하다
이는 자바 메모리 모델 때문이다.

 

동기화의 관점에서의 자바의 메모리 모델

동기화를 하지 않으면 스레드가 변수를 읽어올 때 각 쓰레드가 변수를 cached한 영역에서 읽어오게 된다. 그래서 한 쓰레드로 인해 해당 변수가 값이 변화해도, 다른 쓰레드에서는 이전에 읽었던 cached된 변수의 값을 읽기 때문에 변경된 사항을 볼 수 없다.

따라서 각 쓰레드에서 변경한 값을 값을 통신하기 위해 동기화가 필요하며. 이때 통신을 위한 동기화를 사용하기 위해서는 volatile 한정자를 사용하는 방법이 있다. (Synchronized는 배타적수행, 안정적 통신을 모두수행하는 것이고, volatile은 안정적 통신만을 수행한다고 생각하면 편하다)

즉, 여러 스레드가 공유하는 변수값을 읽어오기 위해서 volatile 키워드를 붙이면 그 변수를 읽어올때 각 쓰레드의 cached한 영역이 아닌 메인 메모리에서 직접 읽어오기 때문에 안정적인 통신을 보장할 수 있다.

 

공식문서에 있는 자바 메모리 모델에 대한 설명

volatile 변수의 경우에는 inter-thread action에 해당하여, synchronized된 경우의 자바 메모리 모델 reordering 규칙이 적용된다. (Reordering은 다른 쓰레드의 변수값을 읽어오기 위한 작업으로, 한 쓰레드의 변경사항이 다른 쓰레드에 표시될 수 있게 하기 위한 작업이라 생각하자.)
이 규칙은 volatile 변수가 쓰기가 일어날 경우에는, 항상 임의의 읽기 쓰레드에 의해서 동기화가 되도록 reordering되는 것을 의미하며, reordering이 된다는 것은 다른 쓰레드에서 변수를 읽을 때 최신 변경사항을 읽을 수 있다는 것이다.

좀더 자세한 내용을 보고싶다면 더보기 클릭

더보기

https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

위 글에서는 자바의 메모리 모델에 대해 설명하며, 동기화 되지 않은 프로그램이 어떻게 놀라운 결과를 나타내는지를 보여주고 있다.

자바의 메모리 모델은 한 실행에 대해서 각각의 읽기를 검사하고, 특정 규칙에 따라 읽기가 쓰기를 옵저빙하면서 쓰기가 유효한지를 확인하여 작동한다.

격리된 각 쓰레드의 동작은 표시되는 값이 메모리 모델에 의해 결정되는 경우를 제외하고, 각 쓰레드의 의미에 따라 제어되는 방식으로 작동한다. (intra-thread semantics). 

즉, 격리된 각 쓰레드의 동작이 메모리 모델에 의해 결정되는 경우는 멀티쓰레드에서 표시되는 값을 기반으로 이해해야하는 경우를 이야기 한다. 

intra-thread semantics : 싱글 쓰레드에서 스레드 안에서만 표시되는 값을 기반으로 스레드 동작을 예측 가능하다
inter-thread action : 한 스레드에서 수행되어 다른 스레드에의해 직접 감지되거나 영향을 받는 작업

 

자바의 메모리 모델의 reordering

왼쪽 : reordering되기 전 / 오른쪽 : reordering된 후

왼쪽의 경우에 r2 == 2, r1 == 1이 되는것이 불가능해 보인다. 하지만, 컴파일러는 스레드의 실행에 영향을 미치지 않는 경우 두 스레드에서 명령어를 다시 정렬할 수 있다 (오른쪽 사진처럼 정렬). d

불가능한 이유 : 1번이 먼저오면 4번에 의한 쓰기 결과를 볼 수없고, 3번이 먼저오면 2번에 의한 쓰기 결과를 볼 수 없기 때문이다.

하지만 오른쪽의 경우에는 동기화가 되지 않았다.

  • 한개의 thread에서는 쓰기를 하고있고
  • 같은 변수값을 다른 쓰레드에서 읽고있고
  • 쓰기와 읽기는 동기화에 의해 정렬되지 않았다 : 동기화에 의한 정렬에 대한 설명은 17.4.4에 있다.

 

동기화의 경우에 자바 메모모리 모델의 reordering

volatile 변수에 대한 쓰기는 임의의 쓰레드에 의해 해당 변수를 subsequent read(동기화 순서에 의해 정의된 read)를 하여 동기화된다. -> volatile 변수의 변경 사항은 항상 다른 쓰레드에 표시된다.

 

long과 double

위에서 "자바 언어에서 long과 double을 제외한 변수를 읽고 쓰는 동작은 원자적이다." 라고 언급했다. 그렇다면 long과 double은 왜 변수를 읽고 쓰는 동작이 원자적이지 않을까?

JVM 비트수와 관련이 있다. 자바 메모리 모델에 의하면 32비트 메모리에 값을 할당하는 연산은 중단이 불가능하다. (원자적이다)
그렇지만 long과 double의 경우에는 64비트의 메모리 공간을 갖고있기 때문이다.

https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

해당 글을 읽어보면, 3가지로 요약이 된다.

  • volatile이 설정되지 않은 long, doulbe에 대한 쓰기는 두번에 이루어진다
    => 먼저 첫번째 32비트를 쓰고 다음 쓰기에서 두번째 32비트를 쓴다.
  • volatile이 설정된 long, double이라면 항상 원자적이다
  • 프로그래머는 shared 되는 62bit 값은 volatile이나 synchronize 로 선언하는게 좋다. complication을 피하기 위해!

즉 첫 비트 32비트 값을 할당한 직후에, 즉 둘째 32비트를 할당하기 직전에 다른 쓰레드가 끼어들어 두 32비트 값중 하나를 변경할 수 있기 때문에 long, double은 원자적 연산이 될 수 없다.

하지만 volatile을 사용을 한다는 것은 여러 쓰레드에서 하나의 변수가 같은 값을 읽도록 보장하는 것이기 때문에, 메모리를 2번 접근을 하더라도 같은 값을 읽도록하는. 변수에 접근하는 연산을 원자적으로 수행하게 보장한다는 것이다.

in which case the Java memory model ensures that all threads see a consistent value for the variable

따라서 long, double 변수를 원자적으로 사용하고 싶다면 volatile로 선언하는게 좋다.

I came across this while reading Effective Java and looking into the racy single-check idiom.

 

Functions of Synchronization

If you've done any thread programming in Java, you've probably encountered the synchronized keyword a few times. In synchronization, synchronized lets you safely modify a shared resource when multiple threads access it simultaneously, so it's easy to think that's all synchronization does. In other words, it ensures that only one thread at a time can execute a synchronized block or method.

But in fact, synchronization has two functions in total.

 

a. Mutual Exclusion

As mentioned above, this refers to preventing other threads from seeing a shared object while one thread is modifying it and its state is inconsistent — only the thread currently using the object can access it.

This is where the concept of a lock comes in. When you write a method that acquires a lock to check and potentially modify an object's state, once a thread enters that method, the object becomes locked, and other threads cannot access it simultaneously.

In short, mutual exclusion is about transitioning an object from one consistent state to another consistent state.

 

b. Reliable Communication Between Threads

I previously only knew about point (a), but there's another important function of synchronization.

Without synchronization, changes made by one thread may not be visible to other threads.
Thanks to synchronization, modifications performed under the protection of a lock in one thread can be seen as the final result by other threads.

In the Java language, reading and writing variables is atomic for all types except long and double. Even if multiple threads modify a primitive variable without synchronization, each thread will read the value correctly and completely (without being interrupted mid-operation).

 

Atomic Operations

I mentioned above that read and write operations are atomic. An atomic operation is one that cannot be interrupted.
Many Java operations are composed of bytecode instructions, and if another thread can intervene during the bytecode execution of an operation and cause incorrect results, then that operation is not atomic.

A classic example of a non-atomic operation is a++ (the increment operator). The concurrency appendix of the Clean Code book explains it like this:

Assume lastId had a value of 42. Here is the bytecode for the getNextId method.
For example, suppose the first thread executes up to ALOAD 0, DUP, GETFIELD lastId and then gets interrupted.
The second thread cuts in and executes all the instructions — it increments lastId and gets 43.
Now the first thread resumes execution from where it was interrupted.
When the first thread executed GETFIELD lastId, the value of lastId was 42. So 42 was on the operand stack. It adds 1 to get 43 and stores the result.
The first thread also returns 43. The value incremented by the second thread is lost.
This happened because the second thread interrupted the first thread, and when the first thread resumed, it overwrote the second thread's work.

The problem here is that when performing operations, if the operation is not atomic during the process of storing values in the JVM's frame, local variables, and operand stack, intermediate results can get overwritten, leading to incorrect values.

To put it more simply: two threads each performed a ++ operation, so the result should have been +2, but since the ++ operation is not atomic, only +1 was applied.

 

Synchronization with Atomic Data

After hearing about atomicity above, you might think that you don't need synchronization when reading and writing atomic data (since assignment operations are atomic). (Because they can't be interrupted!)

However, even with atomic data, synchronization is necessary.

The Java language does not guarantee that when a thread reads a field (even if the data is atomic), it will get a 'fully updated' value.
In other words, even if thread A modifies a field, thread B is not guaranteed to see the modified value.

Therefore, reliable communication through synchronization is needed even just to ensure that a field value modified by one thread is 'properly read' by another thread.
This is due to the Java Memory Model.

 

Java Memory Model from a Synchronization Perspective

Without synchronization, when a thread reads a variable, it reads from its own cached area. So even if one thread changes the variable's value, other threads still read the previously cached value and cannot see the change.

Therefore, synchronization is needed for threads to communicate changed values. One way to achieve communication-only synchronization is by using the volatile modifier. (Think of it this way: synchronized provides both mutual exclusion and reliable communication, while volatile only provides reliable communication.)

In other words, when you add the volatile keyword to a shared variable, reading that variable goes directly to main memory instead of each thread's cached area, which guarantees reliable communication.

 

Java Memory Model Explained in the Official Documentation

Volatile variables are considered inter-thread actions, so the Java Memory Model's reordering rules for synchronized cases apply. (Think of reordering as the mechanism for reading variable values from other threads — it's what makes changes in one thread visible to other threads.)
This rule means that writes to volatile variables are always reordered so that they are synchronized by any reading thread through subsequent reads (as defined by synchronization order). Being reordered means that other threads can read the latest changes when they access the variable.

Click "Show More" for more details

더보기

https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

The article above explains Java's memory model and demonstrates how unsynchronized programs can produce surprising results.

Java's memory model works by examining each read in an execution and checking whether the write being observed by the read is valid according to specific rules.

The behavior of each isolated thread operates in a manner controlled by that thread's semantics, except when the values it sees are determined by the memory model (intra-thread semantics). 

In other words, when the behavior of an isolated thread is determined by the memory model, it needs to be understood based on values visible in a multithreaded context. 

intra-thread semantics: In a single thread, the thread's behavior is predictable based on values visible only within that thread.
inter-thread action: An action performed by one thread that can be directly detected or affected by another thread.

 

Reordering in the Java Memory Model with Synchronization

Left: Before reordering / Right: After reordering

In the left case, it seems impossible for r2 == 2 and r1 == 1. However, the compiler can reorder instructions in both threads if it doesn't affect each thread's execution (as shown in the right image).

Why it seems impossible: If instruction 1 comes first, it can't see the write result from instruction 4. If instruction 3 comes first, it can't see the write result from instruction 2.

But in the right case, there is no synchronization.

  • One thread is writing
  • Another thread is reading the same variable
  • The writes and reads are not ordered by synchronization: the explanation of synchronization ordering is in section 17.4.4.

 

Reordering in the Java Memory Model with Synchronization

A write to a volatile variable is synchronized with any subsequent read (a read defined by synchronization order) of that variable by any thread. → Changes to a volatile variable are always visible to other threads.

 

long and double

I mentioned above that "in the Java language, reading and writing variables is atomic for all types except long and double." So why aren't read and write operations atomic for long and double?

It's related to the JVM's bit width. According to the Java memory model, assigning a value to 32-bit memory is an uninterruptible operation (i.e., atomic).
However, long and double occupy 64-bit memory space.

https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

Reading through that article, it can be summarized in three points:

  • Writes to non-volatile long and double are done in two steps.
    => The first 32 bits are written first, then the second 32 bits are written next.
  • If long or double is declared volatile, the operation is always atomic.
  • Programmers should declare shared 64-bit values as volatile or synchronized to avoid complications.

In other words, right after assigning the first 32 bits — just before assigning the second 32 bits — another thread can cut in and modify one of the two 32-bit values. That's why long and double cannot be atomic operations.

However, using volatile guarantees that multiple threads read the same value from a single variable. So even though memory is accessed twice, it ensures the same value is read — meaning the variable access operation is guaranteed to be performed atomically.

in which case the Java memory model ensures that all threads see a consistent value for the variable

Therefore, if you want to use long or double variables atomically, it's best to declare them as volatile.

댓글

Comments

Develop/JAVA

자바의 제네릭 타입 소거, 리스트에 관하여 (Java Generics Type Erasure, List) | Java's Generic Type Erasure, Regarding Lists (Java Generics Type Erasure, List)

1. 자바의 제네릭과 로타입 (Java Generics and Raw Type)public class Example{ private T member;}위와 같이 클래스 및 인터페이스 선언에 타입 매개변수 T 가 사용되면 제네릭 클래스, 제네릭 인터페이스라고 말하는데, 이때 사용된 이 클래스 Example 를 제네릭타입이라고 이야기한다.제네릭을 사용하면 로타입이라는 개념이 나오는데, 로타입은 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않았을 때를 의미한다 즉, 위 제네릭 타입Example를 Example 로만 선언하여 사용했을 경우를 말한다.public class Example { private T member; public Example(T member) { this.membe..

자바의 제네릭 타입 소거, 리스트에 관하여 (Java Generics Type Erasure, List) | Java's Generic Type Erasure, Regarding Lists (Java Generics Type Erasure, List)

728x90

1. 자바의 제네릭과 로타입 (Java Generics and Raw Type)

public class<T> Example{
	private T member;
}

위와 같이 클래스 및 인터페이스 선언에 타입 매개변수 T 가 사용되면 제네릭 클래스, 제네릭 인터페이스라고 말하는데, 이때 사용된 이 클래스 Example<T> 를 제네릭타입이라고 이야기한다.

제네릭을 사용하면 로타입이라는 개념이 나오는데, 로타입은 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않았을 때를 의미한다 즉, 위 제네릭 타입Example<T>Example 로만 선언하여 사용했을 경우를 말한다.

public class Example<T> {
    private T member;

    public Example(T member) {
        this.member = member;
    }

    public static void main(String[] args) {
        Example<Integer> parameterType = new Example<>(1);
        Integer parameterTypeMember = parameterType.member;
        System.out.println(parameterTypeMember);

        Example rawType = new Example(1);
        Object rawTypeMember = rawType.member;
        System.out.println(rawTypeMember);
    }
}

위 코드는 제네릭 파라미터 타입과 로타입을 사용한 경우이다. 하지만 로타입은 사용하지말자.

제네릭의 장점은 컴파일 타임에 타입에 대한 안정성을 보장받을 수 있다는 점이다. 제네릭 타입으로 선언한 변수는 컴파일 타임에 타입 체크를 하기 때문에 런타임에서 ClassCastException과 같은 UncheckedException을 보장 받을 수 있다.

반면 아래와 같이 로타입으로 사용될 경우에는 제네릭을 사용했을 때의 안정성과 표현력이라는 장점을 발휘할 수 없기 때문에, IDE 에서도 "Raw use of parameterized class 'Example' " 라는 경고를 주는 것을 볼 수 있다.

그럼 로타입이 나오게 된 이유는 무엇일까? 로타입이 나오게 된 이유는 제네릭의 특징인 소거와 관련이 있다.

제네릭은 JDK5 에서 도입이되었다. 버그를 줄이기 위한 목적과, 다른 추상화된 타입에 대한 레이어를 추가하기 위해서이다.
이에 따라 제네릭을 도입한 JDK5는 기존의 코드를 모두 수용하면서 제네릭을 사용하는 새로운 코드와의 호환성을 유지 했어야 했다. 따라서 코드의 호환성 때문에 : 로타입의 지원 + 제네릭을 구현할 때 소거(erasure)하는 방식을 이용하였다.

 

2. 제네릭의 타입소거 (Generics Type Erasure)

소거란 원소 타입을 컴파일 타임에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것이다. 다른 말로는 컴파일 타임에만 타입에 대한 제약 조건을 적용하고, 런타임에는 타입에 대한 정보를 소거하는 프로세스이다.

List<Object> ol = new ArrayList<Long>(); // 컴파일 에러
ol.add("타입이 달라 넣을 수 없다");

다음과 같은 상황에서 컴파일시에 타입 오류를 바로 알 수 있다 (리스트도 제네릭 타입으로 구현되어있기 때문에)

Java 컴파일러는 타입소거를 아래와 같이 적용한다.

  • 제네릭 타입( Example<T>) 에서는 해당하는 타입 파라미터 (T) 나 Object로 변경해준다. 
    Object로 변경하는 경우는 unbounded 된 경우를 뜻하며, 이는 <E extends Comparable<E>>와 같이 bound를 해주지 않은 경우를 의미한다.
    따라서 이 소거 규칙에 대한 바이트코드는 제네릭을 적용할 수 있는 일반 클래스, 인터페이스, 메서드에만 해당된다.
  • 타입 안정성 보존을 위해 필요하다면 type casting을 넣어준다.
  • 확장된 제네릭 타입에서 다형성을 보존하기 위해 bridege method를 생성한다.
public static <E> boolean containsElement(E[] elements, E element) {
	for (E e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

실제로 이렇게 선언되어있는 제네릭 메서드의 경우 선언 방식에 따라 컴파일러가 타입파라미터 E를 변경한다.

public static boolean containsElement(Object[] elements, Object element) {
	for (Object e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

컴파일러는 첫번째 규칙에 따라 타입 파라미터 E가 bound하게 선언되어있지 않기 때문에 타입 파라미터 EInteger로 우선적으로 바꾼다.

이때 만약 프로그래머가 continasElement(Integer[], Integer) 형식으로 해당 메서드를  사용했다면, 컴파일러 내부에서 두번재 규칙에 따라 타입 안정성 보존을 위해 Object -> Integer로의 타입 캐스트 코드를 넣어주어 제네릭의 타입 안정성을 보장해주는 것이다. 

반면 로타입일 경우에는, 타입 파라미터가 정해져있지 않아. Object로 변환한 것에서 끝난다.

더보기

반면 타입 파라미터 E를 bound하게 설정한다면 

public static <E extends Comparable<E>> void containsElement(E[] elements) {
	for (E e : elements) {
		System.out.println("%s", e);
	}
}

타입이 소거될때 Object로 바뀌는 것이 아닌 한정시킨 타입인 Comparable로 변환된다.

public static void containsElement(Comparable[] elements) {
	for (Comparable e : elements) {
		System.out.println("%s", e);
	}
}

추가로 세번째 규칙에 대해서 언급하자면 java compiler는 제네릭의 타입안정성을 위해 Bridge Method도 만들어낼 수있다. Bridge Method는 java 컴파일러가 컴파일 할 때 메서드 시그니처가 조금 다르거나 애매할 경우에대비하여 작성된 메서드이다. 이 경우는 파리미터화된 클래스나 인터페이스를 확장한 클래스를 컴파일 할 때 생길 수 있다.

public class IntegerStack extends Stack<Integer> {
    public Integer push(Integer value) {
        super.push(value);
        return value;
    }
}

 

Java 컴파일러는 다형성을 제네릭 타입 소거에서도 지키기 위해, IntegerStackpush(Integer) 메서드와 Stack의 push(Object) 메서드 시그니처 사이에 불일치가 없어야 했다. 따라서 컴파일러는 런타임에 해당 제네릭 타입의 타입소거를 위한 Bridge 메서드를 만드는데 아래와같은 방식으로 만든다.

public class IntegerStack extends Stack {
    // Bridge method generated by the compiler
     
    public Integer push(Object value) {
        return push((Integer)value);
    }
 
    public Integer push(Integer value) {
        return super.push(value);
    }
}

extends Stack<Integer> -> Stack 으로  변경한 것을 볼 수 있으며, push에 parameter를 Object가 아닌 Integer로 맞추기 위한 도우미 메서드가 늘어났다는 것을 알 수 있다. 결과적으로 Stack 클래스의 push method는 타입소거를 진행한 후에, IntegerStack 클래스의 원본 push 방법을 사용하게 한다.

 

3. 제네릭에서는 리스트를 사용하자

실체화 불가 타입(Non-Reifiable Type)에 대한 설명이 있다 runtime에 타입 정보를 갖고있지 않고, compile-time에 타입 소거가 되는 타입을 의미한다고 한다. 이에 반대하는 개념으로는 실체화(reifiable)가 있다. 이는 타입 정보를 런타임에 완벽하게 사용할 수 있는 유형으로, 소거와는 반대 개념이다. 

실체화 불가 타입의 대표적인 예시로는 List<String> List<Number> 와 같은 리스트가 있고, 실체화 타입의 대표적인 예시로는 String[], Number[] 와 같은 배열이 있다.

이펙티브 자바에서는 타입소거라는 특성이 있는 제네릭은 실체화 불가 타입인 List와 함께 사용하기를 권장한다. Array 에서는 런타임에 타입정보를 갖고있는데, 제네릭을 사용하면 타입이 소거되기 때문에 해당 제네릭 변수에 대한 정보를 런타임에 갖고있지 않기 때문이다.

사실 이 부분은 이펙티브 자바를 읽으면서 스터디에서 했던 제네릭과 관련한 이야기를 예시로 이야기 하려한다.

관련 이슈 : https://github.com/Java-Bom/ReadingRecord/issues/88

 

[아이템 32] toArray · Issue #88 · Java-Bom/ReadingRecord

193p 마지막 코드에서 toArray를 바로 String[]배열로 받는건 되는데 왜 pickTwo를 거쳐서 String[]로 받는건 안될까?

github.com

static <T> T[] pickTwo(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return toArray(a, b);
    case 1:
      return toArray(b, c);
    case 2:
      return toArray(a, c);
  }
  throw new AssertionError();
}

static <T> T[] toArray(T... args) {
  return args;
}

public static void main(String[] args) {
  String[] strings = pickTwo("좋은", "빠른", "저렴한");  
}

위 코드에서 ClassCastException이 터진다. 반면 아래와 같이 pickTwo 메서드를 사용했을 때는 ClassCastException이 터지지 않는다. 왜그럴까? 

static <T> List<T> pickTwoList(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return Arrays.asList(a, b);
    case 1:
      return Arrays.asList(b, c);
    case 2:
      return Arrays.asList(a, c);
  }
  throw new AssertionError();
}

List<String> strings = pickTwoList("좋은", "빠른", "저렴한");

문제를 좀 더 단순히 해보자.

static <T> List<T> pickList(T a) {
        return Arrays.asList(a);
    }

    static <T> T[] toArray(T... args) {
        return args;
    }

    static <T> T[] pickArray(T a) {
        return toArray(a);
    }

    public static void main(String[] args) {
        List<String> stringList = pickList("자바봄");
        String[] stringArray = pickArray("자바봄");
   }
}

위 코드에서 pickList를 지나 pickArray를 실행하면 runtimeException이 터지는 것을 볼 수 있다. 내용은 [Ljava.lang.Object; cannot be cast to [Ljava.lang.String; 이다 무엇이 문제일까?

정답은 Array와 List의 실체화에 있었다. 

코드를 이해해보자. 위에서는 제네릭 타입추론이 2 depth가 들어간다. pickArray에서 타입 매개변수 TString과 대응하여 들어가기 때문에 pickArray(String) toArray(String...) 이 들어갈 것으로 예상한다. 하지만 실제 런타임에 확인해보면 pickArray(String),  toArray(Object...)가 들어간다. 그 이유는 위에서 제네릭 타입추론을 이야기할 때 우선적으로 bounded가 아닌 매개변수일경우 컴파일러가 Object로 대체한다는 이야기와 대응된다. pickArray에서는 main에 있는 String 타입으로 타입추론이 가능했으나 toArray는 제네릭 타입을 바라보고 있으므로 타입추론을 Object로 하여 런타임에 타입정보를 갖고있게 된다.

따라서 pickArrayString[] 으로 타입캐스팅을 준비하였으나, 위에서 말한 것처럼 런타임에 toArray가 갖고있는 타입은 Object[] 이므로 런타임에 (String[]) Object[] 와 같은 형식으로 강제적으로 타입캐스팅을 하다가 ClassCastException이 발생한다. (String 배열은, Object 배열의 하위 타입이 아니기 때문에 Casting이 되지 않는다.)

반면에 pickList를 호출할때는 컴파일이 성공한다. 이 이유는 List가 실체화 불가타입이었기 때문이다. 컴파일 타임에 캐스팅할 정보가 이미 결정이되고, 런타임때에는 제네릭의 소거라는 특성 때문에 Java 컴파일러가 타입에 맞는 캐스팅 방식을 올바르게 추가해줘서 캐스팅 에러가 나지 않는다.

정리하자면, 리스트 + 제네릭은 컴파일 타임에 결정된 캐스팅 정보가 올바르기 때문에 통과가 된다. 제네릭의 장점인 컴파일 타임에 타입이 안맞는 것을 체크해주는 걸 List에서도 수행하기 때문이다. 반면 배열 같은 경우엔 타입 캐스팅을 런타임에 결정하기 때문에 문제가 생긴 것이다. 

 

조금 길었지만 사실 결론은 간단하다. 제네릭은 타입소거라는 특징으로 컴파일러가 컴파일 타임에 타입을 추론할 수 있으며, 이런 타입 추론 기능을 강력하게 사용하기 위해서는 런타임에 타입을 추론하는 Array 대신에 컴파일타임에 타임을 추론하는 List를 함께 사용해야 안정성을 보장 할 수 있다는 것이다.

 

참고 글

https://www.baeldung.com/java-generics

Effecitve Java 3/E - Chapter 5: Generics 

https://docs.oracle.com/javase/tutorial/java/generics/erasure.html

https://www.baeldung.com/java-type-erasure

 

1. Java Generics and Raw Type

public class<T> Example{
	private T member;
}

When a type parameter T is used in a class or interface declaration like above, we call it a generic class or generic interface. The class Example<T> used here is referred to as a generic type.

When using generics, the concept of raw types comes up. A raw type is when you don't use a type parameter at all with a generic type — in other words, when you declare and use the generic type Example<T> as just Example.

public class Example<T> {
    private T member;

    public Example(T member) {
        this.member = member;
    }

    public static void main(String[] args) {
        Example<Integer> parameterType = new Example<>(1);
        Integer parameterTypeMember = parameterType.member;
        System.out.println(parameterTypeMember);

        Example rawType = new Example(1);
        Object rawTypeMember = rawType.member;
        System.out.println(rawTypeMember);
    }
}

The code above shows both a generic parameterized type and a raw type in use. But don't use raw types.

The advantage of generics is that they guarantee type safety at compile time. Since variables declared with a generic type are type-checked at compile time, you're protected from UncheckedException errors like ClassCastException at runtime.

On the other hand, when used as a raw type like below, you lose the benefits of safety and expressiveness that generics provide. That's why you can see the IDE giving you a warning like "Raw use of parameterized class 'Example' ".

So why do raw types exist in the first place? The reason raw types came about is related to erasure, a key characteristic of generics.

Generics were introduced in JDK5. The goals were to reduce bugs and to add a layer of abstraction over types.
Because of this, JDK5 — which introduced generics — had to accommodate all existing code while maintaining compatibility with new code that uses generics. So for the sake of code compatibility, they supported raw types and implemented generics using erasure.

 

2. Generics Type Erasure

Erasure means that element types are only checked at compile time and the type information is not available at runtime. In other words, it's a process where type constraints are enforced only at compile time, and type information is erased at runtime.

List<Object> ol = new ArrayList<Long>(); // 컴파일 에러
ol.add("타입이 달라 넣을 수 없다");

In a situation like this, you can immediately catch the type error at compile time (because List is also implemented as a generic type).

The Java compiler applies type erasure as follows:

  • In a generic type (Example<T>), it replaces the type parameter (T) with the corresponding type or Object
    Replacing with Object happens when the type is unbounded — meaning it hasn't been bounded like <E extends Comparable<E>>.
    Therefore, the bytecode for this erasure rule only applies to regular classes, interfaces, and methods that can use generics.
  • It inserts type casting where necessary to preserve type safety.
  • It generates bridge methods to preserve polymorphism in extended generic types.
public static <E> boolean containsElement(E[] elements, E element) {
	for (E e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

For a generic method declared like this, the compiler replaces the type parameter E depending on how it's declared.

public static boolean containsElement(Object[] elements, Object element) {
	for (Object e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

Following the first rule, since the type parameter E is not declared as bounded, the compiler first replaces the type parameter E with Integer.

At this point, if the programmer used the method in the form of containsElement(Integer[], Integer), the compiler internally inserts type casting code from Object to Integer according to the second rule to preserve type safety, thus guaranteeing the type safety of generics.

In the case of raw types, however, since no type parameter is specified, it simply ends with the conversion to Object.

더보기

On the other hand, if you set the type parameter E as bounded:

public static <E extends Comparable<E>> void containsElement(E[] elements) {
	for (E e : elements) {
		System.out.println("%s", e);
	}
}

When the type is erased, instead of being replaced with Object, it gets converted to the bounded type Comparable.

public static void containsElement(Comparable[] elements) {
	for (Comparable e : elements) {
		System.out.println("%s", e);
	}
}

Additionally, regarding the third rule, the Java compiler can also generate Bridge Methods for generic type safety. A Bridge Method is a method created by the Java compiler during compilation to handle cases where method signatures are slightly different or ambiguous. This can happen when compiling a class that extends a parameterized class or interface.

public class IntegerStack extends Stack<Integer> {
    public Integer push(Integer value) {
        super.push(value);
        return value;
    }
}

 

The Java compiler needed to ensure there was no mismatch between the push(Integer) method signature of IntegerStack and the push(Object) method signature of Stack, in order to preserve polymorphism even during generic type erasure. So the compiler creates a Bridge method for the type erasure of that generic type at runtime, and it does it like this:

public class IntegerStack extends Stack {
    // Bridge method generated by the compiler
     
    public Integer push(Object value) {
        return push((Integer)value);
    }
 
    public Integer push(Integer value) {
        return super.push(value);
    }
}

You can see that extends Stack<Integer> has been changed to just Stack, and a helper method has been added to match the push parameter to Integer instead of Object. As a result, the Stack class's push method, after type erasure, delegates to the original push method of the IntegerStack class.

 

3. Use Lists with Generics

Non-Reifiable Type refers to a type that doesn't hold type information at runtime and undergoes type erasure at compile time. The opposite concept is reifiable, which refers to types whose type information is fully available at runtime — the opposite of erasure. 

Typical examples of non-reifiable types include lists like List<String> and List<Number>, while typical examples of reifiable types include arrays like String[] and Number[].

Effective Java recommends using generics — which have the characteristic of type erasure — with List, a non-reifiable type. This is because arrays hold type information at runtime, but when you use generics, the type gets erased, so the generic variable's information isn't available at runtime.

This part is actually something I want to illustrate with an example from a study group discussion about generics that we had while reading Effective Java.

Related issue : https://github.com/Java-Bom/ReadingRecord/issues/88

 

[Item 32] toArray · Issue #88 · Java-Bom/ReadingRecord

On p.193, the last code example — receiving toArray directly as a String[] array works, but why doesn't it work when receiving as String[] through pickTwo?

github.com

static <T> T[] pickTwo(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return toArray(a, b);
    case 1:
      return toArray(b, c);
    case 2:
      return toArray(a, c);
  }
  throw new AssertionError();
}

static <T> T[] toArray(T... args) {
  return args;
}

public static void main(String[] args) {
  String[] strings = pickTwo("좋은", "빠른", "저렴한");  
}

In the code above, a ClassCastException is thrown. However, when using the pickTwo method like below, no ClassCastException occurs. Why is that?

static <T> List<T> pickTwoList(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return Arrays.asList(a, b);
    case 1:
      return Arrays.asList(b, c);
    case 2:
      return Arrays.asList(a, c);
  }
  throw new AssertionError();
}

List<String> strings = pickTwoList("좋은", "빠른", "저렴한");

Let's simplify the problem a bit.

static <T> List<T> pickList(T a) {
        return Arrays.asList(a);
    }

    static <T> T[] toArray(T... args) {
        return args;
    }

    static <T> T[] pickArray(T a) {
        return toArray(a);
    }

    public static void main(String[] args) {
        List<String> stringList = pickList("자바봄");
        String[] stringArray = pickArray("자바봄");
   }
}

In the code above, after pickList passes, executing pickArray throws a runtimeException. The message is [Ljava.lang.Object; cannot be cast to [Ljava.lang.String; — what's the problem?

The answer lies in the reifiability of Array vs. List.

Let's understand the code. Here, generic type inference goes 2 levels deep. Since the type parameter T in pickArray corresponds to String, you'd expect pickArray(String) toArray(String...) to be called. But when you actually check at runtime, it's pickArray(String), toArray(Object...) that gets called. The reason is exactly what we discussed above about generic type inference — when the parameter is unbounded, the compiler replaces it with Object first. While pickArray could infer the String type from main, toArray looks at the generic type, so it infers Object and holds that type information at runtime.

Therefore, pickArray prepares to cast to String[], but as mentioned above, the type that toArray holds at runtime is Object[], so at runtime it tries to forcefully cast like (String[]) Object[], which causes a ClassCastException. (A String array is not a subtype of an Object array, so the cast fails.)

On the other hand, calling pickList compiles successfully. The reason is that List is a non-reifiable type. The casting information is already determined at compile time, and at runtime, thanks to the erasure characteristic of generics, the Java compiler correctly adds the appropriate casting, so no casting error occurs.

To summarize, List + generics works because the casting information determined at compile time is correct. List performs the same compile-time type mismatch checking that is the advantage of generics. Arrays, on the other hand, determine type casting at runtime, which is where the problem arises.

 

That was a bit long, but the conclusion is actually simple. Generics use type erasure, which allows the compiler to infer types at compile time. To fully leverage this type inference capability, you should use List — which infers types at compile time — instead of Array — which infers types at runtime — to guarantee type safety.

 

References

https://www.baeldung.com/java-generics

Effecitve Java 3/E - Chapter 5: Generics 

https://docs.oracle.com/javase/tutorial/java/generics/erasure.html

https://www.baeldung.com/java-type-erasure

 

댓글

Comments