forDevLife
[Java] Call back(Listener) 본문
Call back?
- Callee(피호출자 - listner)에서 Caller(호출자)의 메서드를 역으로 호출하는 동작을 의미
- 다른 함수에 인수로 전달되는 함수를 call back 메서드라고 하며, 특정 이벤트 이후 실행된다.
- call back은 called at the back으로 해석할 수도 있다. 즉 back(이후)에서 호출되는 메서드라고 이해하자.
- 예시로 TestCallBack(Caller)와 Callee를 만들어보자. Caller에서 Callee의 메서드를 호출하면, Callee 내부의 메서드에서 다시 Caller의 메서드를 역으로 호출하게 될 것이다.
1. Callee 선언
import java.util.Scanner;
public class Callee {
private String msg;
private CallBack callBack;
@FunctionalInterface
public interface CallBack{ // inner interface이므로 static이다
void onGetMessage(Callee callee);
}
public Callee() {
this.msg = "";
this.callBack = null;
}
public String getMsg() {
return msg;
}
public void setCallBack(CallBack callBack) {
this.callBack = callBack;
}
public void onInputMessage() {
Scanner scanner = new Scanner(System.in);
this.msg = ""; // 초기화
System.out.print("메시지 입력하세요 : ");
this.msg = scanner.nextLine();
if (this.callBack != null) { // callback 처리
this.callBack.onGetMessage(this);
}
}
}
- Callee 내에 inner Interface인 CallBack을 선언하고, onGetMessage 메서드를 선언한다.
- Callee의 onInputMessage에서 CallBack 메서드인 onGetMessage를 호출한다.
이는 Caller에서 익명 클래스 혹은 람다로 구현될 것이다. - 즉, 외부 Caller가 Callee의 내부 메서드 onGetMessage를 호출하면, 내부에서는 외부 Caller에서 set을 통해 전달받은 익명 클래스(or 람다)를 실행(call back)하도록 구현할 것이다.
2. Caller 선언
public class Caller {
public static void main(String[] args) {
Callee callee = new Callee(); // 콜백 callee가 caller에게 요청(callee는 피호출자 == listner)
callee.setCallBack(new Callee.CallBack() {
@Override
public void onGetMessage(Callee callee) { // callback 메서드. 다른 함수에 인수로 전달되는 함수이며, 이벤트 이후 실행된다.
// callback
System.out.println("입력받은 메시지 > " + callee.getMsg());
}
});
for (int i = 0; i < 5; i++) { // 메시지 발송을 5번까지 보낸다
callee.onInputMessage();
}
}
}
- Caller에서 Callee 객체를 생성하고, setCallBack메서드를 통해 익명 함수(혹은 람다)를 전달한다.
- 아래에서 callee에 구현된 onInputMessage를 호출하게 되면, 내부에서 Scanner로 메시지를 받는다.
- 그 후 메서드 내부에서 앞서 setting 된 onGetMessage에 자기 자신 callee를 전달하여 실행한다.
- 이 과정은 Callee에서 Caller에 구현된 메서드를 실행하는 과정이 된다. 이렇게 call back이 구현되었다.
3. Callback의 메모리 누수
근데 이게 메모리 누수랑 어떤 관련이 있을까? 이펙티브 자바 item 7 - '다 쓴 객체 참조를 해제하라' 에서는 리스너 혹은 콜백이 메모리 누수의 주범이라고 한다. 클라이언트가 callback을 등록하고 명확히 해지하지 않는다면, 콜백이 계속 쌓이기만 한다고 하는데, 이를 알아보자.
앞서 Caller에서 Callee로 callBack 함수를 주입받는다고 아래와 같이 변경하자.
< 변경된 Caller Class >
public class Caller {
private final Callee callee;
private final Callee.CallBack callBack;
public Caller(Callee callee, Callee.CallBack callBack) {
this.callee = callee;
this.callBack = callBack;
}
public void callerMethod() {
// 클라이언트에서 콜백 세팅
callee.setCallBack(callBack);
// 그리고 Callee의 메서드 호출
callee.onInputMessage();
}
}
- Caller는 외부에서 Callee와 callBack을 주입 받고, callerMethod 안에서 주입받은 callBack을 callee로 세팅해준다.
< CallerTest - 외부 참조자에만 null 할당 >
@DisplayName("외부에서 진행한 참조 해제는 의미가 없다.")
@Test
void callBackWithExternalRef() throws InterruptedException {
Callee callee = new Callee();
Callee.CallBack callBack = callee1 -> System.out.println("입력 받은 메시지 > " + callee1.getMsg());
Caller caller = new Caller(callee, callBack); // caller에 직접 주입
caller.callerMethod(); // callback 호출
// 할당한 callBack을 null로 변경, but callBack이라는 참조자에 null을 넣는것으로는 해결이 안된다. 이미 Callee 내부에서 참조되고 있기 때문이다.
callBack = null;
// 계속 살아있다.
assertNotNull(callee.getCallBack());
}
- 외부에서 Callee와 CallBack을 만든 후, caller 생성자에 전달하고 callerMethod를 호출했다. 기존과 동일하게 정상적으로 콜백 동작이 수행될 것이다.
- 더 이상 callBack이 필요가 없어, 외부 참조자 callBack 대상으로 null로 변경했다. 이러면 callBack이 가리키고 있는 람다가 정상적으로 GC 대상으로 넘어갈까? -> NO!
- 참조자 대상으로 null을 수행했지만, 실제로 callee 내부 프로퍼티인 callBack은 람다를 계속 가리키고 있으므로 당연한 결과이다.
- callee에서 getCallBack을 실행하면 null이 아님을 확인할 수 있다. 이처럼 '명확히' 해지하지 않는다면, 쓸모없는 instance는 GC대상이 되지 않고 계속 남아있게 된다.
- 따라서 '명확하게' callee 대상으로도 null 처리가 필요하다.
< CallerTest - 내부에 직접 null 할당 >
@DisplayName("Callee에서 직접 null을 지정해줘야 한다.")
@Test
void callBackWithInnerRef() throws InterruptedException {
Callee callee = new Callee();
Callee.CallBack callBack = callee1 -> System.out.println("입력 받은 메시지 > " + callee1.getMsg());
Caller caller = new Caller(callee, callBack); // caller에 직접 주입
caller.callerMethod(); // callback 호출
// 내부에 직접 null 할당 및 외부도 null 할당
callee.setCallBack(null);
callBack = null;
// 정상 해제되어, 익명 객체는 GC 대상이 된다.
assertNull(callee.getCallBack());
}
- 이전과 다르게, callee.setCallBack을 통해 직접 null을 할당하고, 외부 참조에도 Null을 할당했다.
- 이를 통해 람다는 더이상 참조자가 없으므로 GC 대상이 될 것이다.
- 이와 같이 명확히 해지하는 방법이 있다.
- 또는 콜백을 약한 참조(weak reference)로 저장하면, GC 대상이 된다. 이는 WeakReference로 구현된 WeakHashMap을 사용하면 된다.
- WeakHashMap은 key에 해당하는 '참조자'가 가리키는 객체에 null이 할당되면, 해당 객체를 GC 수집 대상으로 지정한다.
따라서 외부 참조자에 null을 할당해도, 정상적으로 GC 처리가 된다.
< WeakedHashMap 사용 예시 >
@DisplayName("WeakHashMap test with Callee.CallBack key")
@Test
void callBackWithWeakHashMap() {
Map<Callee.CallBack, String> map = new WeakHashMap<>();
Callee.CallBack key1 = callee1 -> System.out.println("input > " + callee1.getMsg());
Callee.CallBack key2 = callee1 -> System.out.println("input > " + callee1.getMsg());
map.put(key1, "1");
map.put(key2, "2");
key1 = null;
System.gc(); // force garbage collect
map.entrySet().forEach(System.out::println); // key1 was not expected to be output, but the result was not.
}
- 하지만 람다식의 경우, 해당 식을 참조하고 있는 key1에 null을 할당해도 map에서 사라지지 않는데, 이유를 잘 모르겠다.
- https://stackoverflow.com/questions/70016544/java-about-weakhashmap-and-lambda-expression
< 변경 : 람다식 -> 익명 클래스 >
@DisplayName("WeakHashMap test with Callee.CallBack key")
@Test
void callBackWithWeakHashMap() throws InterruptedException {
Map<Callee.CallBack, String> map = new WeakHashMap<>();
// Callee.CallBack key1 = callee1 -> System.out.println("input > " + callee1.getMsg());
// Callee.CallBack key2 = callee2 -> {
// System.out.println(callee2.getMsg());
// System.out.println(callee2.getMsg());
// };
Callee.CallBack key1 = new Callee.CallBack() {
@Override
public void onGetMessage(Callee callee) {
System.out.println(callee.getMsg());
}
};
Callee.CallBack key2 = new Callee.CallBack() {
@Override
public void onGetMessage(Callee callee) {
System.out.println(callee.getMsg());
}
};
map.put(key1, "1");
map.put(key2, "2");
key1 = null;
System.gc(); // force garbage collect
map.entrySet().forEach(System.out::println); // key1 was not expected to be output, but the result was not.
System.out.println(map.size());
}
- 익명 클래스로 구현 시, 참조를 해제하면 정상적으로 gc 대상이 된다.
- JVM에서 람다식은 굳이 해제를 안하는 것으로 보인다.
이유는 람다식의 생성 과정에 있다.
- 람다식으로 객체를 생성하는 코드는 invokedynamic으로 변환된다. 이는 컴파일 시점이 아닌 런타임에 동적으로 클래스를 정의하고, 그 인스턴스를 생성해서 반환하는 명세이다.
- 이와 같이 기존의 Invokedynamic이라는 바이트코드 명세를 활용해 차이를 만든다.
- 즉, 람다식으로 구현할 경우, invokedynamic 처리를 통해 하나의 동적 클래스만으로 계속 객체를 생성할 수 있게 된다.
- 따라서 GC에서 예외 처리 되는 것으로 우선 보인다.
람다식과 익명 클래스의 차이
public class ThisDifference {
public static void main(String[] args) {
new ThisDifference().print();
}
public void print() {
Runnable anonClass = new Runnable(){
@Override
public void run() {
verifyRunnable(this);
}
};
anonClass.run();
Runnable lambda = () -> verifyRunnable(this);
lambda.run();
}
private void verifyRunnable(Object obj) {
System.out.println(obj instanceof Runnable);
}
}
- 위의 결과로 익명 클래스(annoClass)의 경우 run을 실행하면 true가 나온다.
- 람다의 run을 실행하면 false가 나온다.
- 람다 Runnable 객체의 this는 Runnable은 아니라는 의미이다. 이처럼 람다와 익명 클래스는 내부 구현이 다르다.
During Compile Time
- 람다식은 컴파일 타임에는 객체를 생성하지 않는다. 다만 런타임에 JVM이 객체를 생성할 수 있도록 람다식을 invokedynamic으로 변환하여, 그 '레시피'를 생성해둔다.
< SimpleLambda.class >
public class SimpleLambda {
public static void main(String[] args) {
String str = "hello";
Runnable lambda= () -> System.out.println(str);
lambda.run();
}
}
< 컴파일 된 클래스를 disassemble - by javap -c -p SimpleLambda.class>
Compiled from "SimpleLambda.java"
public class com.naver.helloworld.resort.SimpleLambda {
public com.naver.helloworld.resort.SimpleLambda();
Code:
0: aload_0
1: invokespecial #8 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: invokedynamic #19, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #20, 1 // InterfaceMethod java/lang/Runnable.run:()V
12: return
private static void lambda$0();
Code:
0: getstatic #29 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iconst_1
4: invokevirtual #35 // Method java/io/PrintStream.println:(I)V
7: return
}
- 위의 main 메서드에서 볼 수 있듯이 람다 표현식으로 객체를 생성하는 코드는 invokedynamic으로 변환되었다.
- 생성된 객체를 실행하는 run 메서드는 invokeinterface로 치환되었다.
- invokedynamic은 다음 3가지 정보를 필요로 한다.
1. bootstrap method : 동적으로 객체를 생성하는 메서드이다. 람다식에서는 LambdaMetafactory.metafactory에 해당된다.
2. static args : 상수 풀에 저장된 정보이다.
3. dynamic args : 메서드의 런타임에서 참조할 수 있는 변수인데, 클로저로 쓰였을 때의 자유 변수가 이에 해당한다.
* 자유 변수(free variable) : 자신을 감싼 영역(closure) 외부에 존재하는 변수를 의미한다.
SimpleLambda를 컴파일한 파일에 javap -v SimpleLambda.class 명령을 실행하면 Bootstrap 메서드를 확인할 수 있다.
다음과 같이 BootstrapMethods 항목에서 LambdaMetafactory.metafactory를 호출한다.
BootstrapMethods:
0: #50 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#51 ()V
#54 invokestatic com/naver/helloworld/resort/SimpleLambda.lambda$0:()V
#55 ()V
- 예제에서는 위의 정보로 각각 다음 내용이 전달된다.
1. bootstrap method : LambdaMetafactory.metafactory()
2. static args : Runnable, SimpleLambda.lambda$0()
3. dynamic args : str 변수 - 여기에서 SimpleLambda.lambda$()라는 private static 메서드가 생겼다. 이는 람다식의 body에 해당한다. 컴파일 타임에 람다식의 body를 그대로 복사한 static 메서드가 람다식을 포함하고 있는 클래스에 추가된다.
- 앞서 Runnable의 람다식 내부에서 this를 호출할 때, Runnable type이 아니였던 이유가 여기에서 설명된다. 람다식 내부에서 this는 람다식을 포함하고 있는 그 클래스 객체를 가리키고, 위의 예제에서는 SimpleLambda가 된다.
- 람다식 코드는 컴파일 타임에 그 클래스의 private static 메서드로 추가된다!
- 컴파일 타임 정리
1. 람다식은 컴파일 타임에 클래스가 정해지지 않고, 런타임에 JVM이 하도록 넘긴다. 그 레시피(invokedynamic)만 전달한다.
2. bootstrap method는 LambdaMetaFactory.metafactory()이다.
3. static args로는 람다식이 구현하는 인터페이스, 람다식 코드를 그대로 복사한 private static 메서드가 전달된다.
4. dynamic args로는 free variable이 전달된다.
During Run Time
컴파일 타임에 제시한 레시피대로 런타임에 JVM이 람다 객체를 생성한다. 앞서 언급한 bootstrap method인 LambdaMetafactory.metafactory()가 람다식의 클래스를 동적으로 정의하고 객체를 반환하는 역할을 한다. 동적으로 정의하는 만큼, 그 시점에서 효율적인 방식으로 JVM이 유연하게 객체를 생성할 수 있다.
여기서 의문점이 생긴다. 앞서 람다식의 코드는 그 클래스의 private static method로 생성되고, Runnable의 run이 수행되면 이 메서드가 실행되는 것이라고 했다. 그 증거가 this가 가리키는 것이 SimpleLambda 객체인 점이었다.
그럼 JVM이 동적으로 생성한 람다식의 객체, Runnable 인터페이스를 구현한 객체는 무엇일까?
< Test class >
public class Test {
public static void main(String[] args) {
Test test = new Test();
test.testMethod();
}
public void testMethod() {
Runnable runnable = () -> {
System.out.println("this: " + this);
throw new RuntimeException();
};
System.out.println("class: " + runnable.getClass());
runnable.run();
}
}
< 실행 결과 >
class: class Test$$Lambda$1/1078694789
this: Test@682a0b20
Exception in thread "main" java.lang.RuntimeException
at Test.lambda$testMethod$0(Test.java:10)
at Test.testMethod(Test.java:13)
at Test.main(Test.java:4)
Process finished with exit code 1
- this는 앞서 살펴봤듯이 람다를 구현한 클래스를 가리킨다. 따라서 Test@~가 출력된다.
- RuntimeException 발생부분에서 Test.lambda$testMethod$0가 확인된다. 이는 앞서 살펴본 static method(람다 body)이다.
- 맨 위의 Test$$Lambda$1이 새롭게 정의된 클래스이다. 즉 '외부클래스이름$$Lambda$번호'와 같은 형식이다.
- 아래 옵션을 통해 동적 클래스를 확인하면 궁금증을 풀 수 있다.
< 람다식으로 정의되는 동적 클래스 확인 - by -Djdk.internal.lambda.dumpProxyClasses >
import java.lang.invoke.LambdaForm.Hidden;
// $FF: synthetic class
final class Test$$Lambda$1 implements Runnable {
private final Test arg$1;
private Test$$Lambda$1(Test var1) {
this.arg$1 = var1;
}
private static Runnable get$Lambda(Test var0) {
return new Test$$Lambda$1(var0);
}
@Hidden
public void run() {
this.arg$1.lambda$testMethod$0();
}
}
- 앞서 출력된 Test$$Lambda$1은 다음과 같이 구현되어 있다.
- run을 보면, args$1 즉 Test의 lambda$testMethod$0()을 호출하고 있다. 이는 람다식 내부 코드가 복사되어 있는 Test 객체의 private static method를 호출한다.
- 람다식으로 생성된 클래스는 위와 같은 실행 메서드에서 단순히 본래 클래스의 private static method만 호출하기 때문에, 구현 객체 하나로 무한하게 재사용이 가능하다. 따라서 익명 클래스에 비해 성능, 자원 면에서 효율적이다.
- 런타임 정리
1. JVM은 컴파일 타임에 만들어진 레시피대로 bootstrap 메서드에 static args, dynamic args를 넘겨, 람다식이 실행되는 시점에 동적으로 클래스를 정의하고 그 객체를 생성해 반환한다.
2. JVM이 동적으로 정의한 람다 클래스는 static args로 넘겨받은 타겟 인터페이스를 구현한다.
3. JVM이 동적으로 정의한 람다 클래스는 실행 메서드(Runnable의 경우 run)에서, 컴파일 타임에 본래 클래스(예제의 Test)에 생성된, 람다식 내부 코드를 담은 Private static method(예제의 lambda$testMethod$0)을 호출한다.
4. 호출된 본래 클래스의 private static method가 실제 람다식 내부 코드를 수행한다.
람다식의 효율성
효율성을 살피기 전에, 추후 나올 용어들을 알아본다.
- Stateless(=Non capturing) lambda : free variable을 참조하지 않는 람다식
- Stateful(=Capturing) lambda : free variable을 참조하는 람다식
람다식의 효율성은 invokedynamic에 의한 동적 객체 생성에 기인한다. 다음 코드를 보자.
< stateless lambda >
public void loopLambda() { // 람다로 구현한 forEach
myStream.forEach(item -> item.doSomething());
}
public void loopAnonymous() { // 익명 클래스로 구현한 forEach
myStream.forEach(new Consumer<Item> () {
@Override
public void accept(Item item) {
item.doSomething();
}
}
}
- 첫 번째 메서드는 람다식으로 구현한 stream forEach문이고, 두 번째 메서드는 익명 클래스로 구현한 문장이다.
- 익명 클래스로 구현한 forEach는 매 iteration마다 익명 클래스를 정의하고 그 객체를 생성한다.
- 하지만 람다식은 단 한 개의 클래스만 생성된다. 람다식으로 생성된 클래스는 그 실행 메서드에서 단순히 본래 클래스의 private static method만 호출하기 때문에, 구현 객체만 생성해도 무한하게 재사용이 가능하다.
- 따라서 익명 클래스에 비해 성능 / 자원 면에서 훨씬 효율적이다.
< stateful lambda >
public class Test {
public static void main(String[] args) {
Test test = new Test();
test.testMethod();
}
public void testMethod() {
int a = 10;
Runnable runnable = () -> {
System.out.println("a: " + a); // free variable 사용
};
runnable.run();
}
}
- 위의 예제를 -Djdk.internal.lambda.dumpProxyClasses 옵션을 사용해 동적 클래스를 저장해 보면,
import java.lang.invoke.LambdaForm.Hidden;
// $FF: synthetic class
final class Test$$Lambda$1 implements Runnable {
private final int arg$1;
private Test$$Lambda$1(int var1) {
this.arg$1 = var1;
}
private static Runnable get$Lambda(int var0) {
return new Test$$Lambda$1(var0);
}
@Hidden
public void run() {
Test.lambda$testMethod$0(this.arg$1);
}
}
- stateless 람다일때와 달리, 생성자를 통해 free variable을 받아 멤버변수에 대입한다.
- free variable이 local variable(stack에 존재)하면 값을 copy하고, heap variable(인스턴스 필드와 같이)이면 reference를 가짐.
- 람다에서 참조할 수 있는 free variable은 final이거나 effectively final이어야 하므로, 그 값이 변경되지 안흔다.
- 따라서 stateful 람다일 때도 마찬가지로 하나의 동적 클래스만으로 계속 재사용이 가능하다.
참고
'Java' 카테고리의 다른 글
[Java] join과 Atomic 처리 (0) | 2022.01.27 |
---|---|
[Java] notify() vs notifyAll() (1) | 2022.01.26 |
[Java] Recursive Type Bound (0) | 2021.11.14 |
[정규식] 문자열 계산기 (0) | 2021.09.30 |
[java] import, static import 문 (0) | 2021.06.09 |