lambda함수의 개념에 대한 설명은 이미 많은 분들이 올려둔게 있으니 넘어가고 우선 자바에서 lambda함수의 구현방식을 Callable객체를 통해 알아보자.
Callable<Integer> callable = () -> {
//do something you want..
return 0;
};
callable.call();
다른 언어들과 비슷하게 위와 같이 사용하거나 다음과 같이 return type을 생략할 수 도 있다.
Callable<Integer> callable = () -> 0;
callable.call();
그렇다면 lambda함수를 받기위한 구현체는 어떤식으로 만들어져 있을까? Callable의 정의로 이동해 보면 다음과 같다.
@FunctionalInterface
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
그렇다. Java의 람다식을 받기위한 구현체는 interface이고 추상메소드를 override하는 식으로 구현된다고 생각하면 된다. 여기서 한가지 제약사항은 interface에 내에 추상메소드가 하나여야 함수형 인터페이스로 동작한다는 것이다. 본래 lambda함수가 추가되기 전인 Java8이전에는 아래와 같은 형태였다고 보면 된다.
Callable<Integer> callable = new Callable<Integer>(){
@Override
public Integer call(){
return 0;
}
};
그렇다면 직접 lambda함수를 구현해 보도록 하자.
public interface Math {
public int calc(int a, int b);
}
public static void main(String[] args) {
Math math = (a,b) -> a + b;
math.calc(1,2);
}
위와 같이 추상메소드가 하나인 인터페이스를 만들고 해당 인터페이스의 객체를 생성하여 lambda함수를 대입하여 사용하면 된다. 그러면 여기서 한가지 의문점이 생기는데 위 Callable객체의 @FunctionalInterface annotation은 무엇일까?
아래와 같은 interface가 존재한다. 아래의 interface는 함수형 인터페이스 일까? 아닐까?
public interface Math {
public int calc(int a, int b);
public boolean equals(Object obj);
}
정답은 함수형 인터페이스가 맞다. 위에선 추상메소드가 하나여야 함수형 인터페이스가 된다고 해놓고 왜 위의 코드는 함수형 인터페이스 냐고 할수있는데 Java의 모든 객체들이 Object로 부터 상속받는 것을 생각하면 된다.(interface도 예외는 아님) 즉 Object의 기본 method들은 함수형 인터페이스를 정의하는데 상관이 없다. 참고로 저 예제에 default함수를 추가해도 함수형 인터페이스 이다.
헷갈린다면 아래처럼 @FunctionalInterface를 달아주자.
@FunctionalInterface
public interface Math {
public int calc(int a, int b);
public boolean equals(Object obj);
public default int calc2(int a, int b)
{
return a+b;
}
}
위와 같이 선언하는 경우는 없을 것 같긴 하지만.. 아무튼
@FunctionalInterface달아주면 interface가 함수형이 맞는지 틀린지 컴파일에러로 알려준다. @FunctionalInterface annotation은 딱히 필수는 아니고 꼭 해주어야 하는 것도 아니지만 해당 interface가 함수형 인터페이스라는 것을 개발자가 헷갈리지 않게 해주는 역할을 해준다고 보면 될 것 같다.
아마 다른 모던(?)한 언어들을 먼저 접하신 분들은 Java의 lambda 사용방법에 있어서 상당히 구리다고 느낄 것이라고 생각한다. 우선 새로운 문법체계를 도입하여 구현된 것이 아니다 보니 일급시민(언어에서 가장제약이 적은 요소를 일급시민이라고 한다)이 아니다. 매개변수의 개수와 반환타입이 달라질 때 마다 interface를 구현해주어야 한다.
그렇다면 이 lambda라는 녀석은 도대체 어떤방식으로 동작하고 있을까? 정말로 구현방식 대로 interface와 동일하게 동작하고 있을까?
우선 아래의 코드를 보자.
public class LambdaTest {
public static void main(String[] args) {
new LambdaTest().print();
}
public void print() {
Runnable runnable = new Runnable(){
@Override
public void run() {
System.out.println(this.getClass().getName());
}
};
runnable.run();
Runnable lambda = () -> System.out.println(this.getClass().getName());
lambda.run();
}
}
이렇게 실행하였을 때 실행결과를 예상해보자. 그냥 평범하게 생각한다면 Runnable과 LambdaTest가 출력될 것이라고 생각할 것이다. 하지만 실제로는 다음과 같이 출력된다.
LambdaTest$1
LambdaTest
LambdaTest의 경우에는 이해하는 것에 무리가 없을 것이다. 그런데 LambdaTest$1는 뭘까? 왜 Runnable이 아닌걸까.
interface의 경우 바이트코드로 컴파일 되면 invokespecial이라는 opcode로 객체가 생성이된다. 그렇다면 람다는 어떨까? 컴파일된 class파일을 javap -v -p -s LambdaTest.class 명령어로 역어셈블리 하여 확인해보자.
public class LambdaTest
minor version: 0
major version: 55
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #1 // LambdaTest
super_class: #3 // java/lang/Object
interfaces: 0, fields: 0, methods: 4, attributes: 4
Constant pool:
#1 = Class #2 // LambdaTest
#2 = Utf8 LambdaTest
#3 = Class #4 // java/lang/Object
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Methodref #3.#9 // java/lang/Object."<init>":()V
#9 = NameAndType #5:#6 // "<init>":()V
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 LLambdaTest;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Methodref #1.#9 // LambdaTest."<init>":()V
#17 = Methodref #1.#18 // LambdaTest.print:()V
#18 = NameAndType #19:#6 // print:()V
#19 = Utf8 print
#20 = Utf8 args
#21 = Utf8 [Ljava/lang/String;
#22 = Class #23 // LambdaTest$1
#23 = Utf8 LambdaTest$1
#24 = Methodref #22.#25 // LambdaTest$1."<init>":(LLambdaTest;)V
#25 = NameAndType #5:#26 // "<init>":(LLambdaTest;)V
#26 = Utf8 (LLambdaTest;)V
#27 = InterfaceMethodref #28.#30 // java/lang/Runnable.run:()V
#28 = Class #29 // java/lang/Runnable
#29 = Utf8 java/lang/Runnable
#30 = NameAndType #31:#6 // run:()V
#31 = Utf8 run
#32 = InvokeDynamic #0:#33 // #0:run:(LLambdaTest;)Ljava/lang/Runnable;
#33 = NameAndType #31:#34 // run:(LLambdaTest;)Ljava/lang/Runnable;
#34 = Utf8 (LLambdaTest;)Ljava/lang/Runnable;
#35 = Utf8 runnable
#36 = Utf8 Ljava/lang/Runnable;
#37 = Utf8 lambda
#38 = Utf8 lambda$0
#39 = Fieldref #40.#42 // java/lang/System.out:Ljava/io/PrintStream;
#40 = Class #41 // java/lang/System
#41 = Utf8 java/lang/System
#42 = NameAndType #43:#44 // out:Ljava/io/PrintStream;
#43 = Utf8 out
#44 = Utf8 Ljava/io/PrintStream;
#45 = Methodref #3.#46 // java/lang/Object.getClass:()Ljava/lang/Class;
#46 = NameAndType #47:#48 // getClass:()Ljava/lang/Class;
#47 = Utf8 getClass
#48 = Utf8 ()Ljava/lang/Class;
#49 = Methodref #50.#52 // java/lang/Class.getName:()Ljava/lang/String;
#50 = Class #51 // java/lang/Class
#51 = Utf8 java/lang/Class
#52 = NameAndType #53:#54 // getName:()Ljava/lang/String;
#53 = Utf8 getName
#54 = Utf8 ()Ljava/lang/String;
#55 = Methodref #56.#58 // java/io/PrintStream.println:(Ljava/lang/String;)V
#56 = Class #57 // java/io/PrintStream
#57 = Utf8 java/io/PrintStream
#58 = NameAndType #59:#60 // println:(Ljava/lang/String;)V
#59 = Utf8 println
#60 = Utf8 (Ljava/lang/String;)V
#61 = Utf8 SourceFile
#62 = Utf8 LambdaTest.java
#63 = Utf8 BootstrapMethods
#64 = Methodref #65.#67 // 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;
#65 = Class #66 // java/lang/invoke/LambdaMetafactory
#66 = Utf8 java/lang/invoke/LambdaMetafactory
#67 = NameAndType #68:#69 // 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;
#68 = Utf8 metafactory
#69 = Utf8 (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;
#70 = MethodHandle 6:#64 // REF_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;
#71 = MethodType #6 // ()V
#72 = Methodref #1.#73 // LambdaTest.lambda$0:()V
#73 = NameAndType #38:#6 // lambda$0:()V
#74 = MethodHandle 7:#72 // REF_invokeSpecial LambdaTest.lambda$0:()V
#75 = MethodType #6 // ()V
#76 = Utf8 InnerClasses
#77 = Class #78 // java/lang/invoke/MethodHandles$Lookup
#78 = Utf8 java/lang/invoke/MethodHandles$Lookup
#79 = Class #80 // java/lang/invoke/MethodHandles
#80 = Utf8 java/lang/invoke/MethodHandles
#81 = Utf8 Lookup
#82 = Utf8 NestMembers
{
public LambdaTest();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #8 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LLambdaTest;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: new #1 // class LambdaTest
3: dup
4: invokespecial #16 // Method "<init>":()V
7: invokevirtual #17 // Method print:()V
10: return
LineNumberTable:
line 3: 0
line 4: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 args [Ljava/lang/String;
public void print();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: new #22 // class LambdaTest$1
3: dup
4: aload_0
5: invokespecial #24 // Method LambdaTest$1."<init>":(LLambdaTest;)V
8: astore_1
9: aload_1
10: invokeinterface #27, 1 // InterfaceMethod java/lang/Runnable.run:()V
15: aload_0
16: invokedynamic #32, 0 // InvokeDynamic #0:run:(LLambdaTest;)Ljava/lang/Runnable;
21: astore_2
22: aload_2
23: invokeinterface #27, 1 // InterfaceMethod java/lang/Runnable.run:()V
28: return
LineNumberTable:
line 7: 0
line 14: 9
line 16: 15
line 17: 22
line 18: 28
LocalVariableTable:
Start Length Slot Name Signature
0 29 0 this LLambdaTest;
9 20 1 runnable Ljava/lang/Runnable;
22 7 2 lambda Ljava/lang/Runnable;
private void lambda$0();
descriptor: ()V
flags: (0x1002) ACC_PRIVATE, ACC_SYNTHETIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #39 // Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_0
4: invokevirtual #45 // Method java/lang/Object.getClass:()Ljava/lang/Class;
7: invokevirtual #49 // Method java/lang/Class.getName:()Ljava/lang/String;
10: invokevirtual #55 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: return
LineNumberTable:
line 16: 0
LocalVariableTable:
Start Length Slot Name Signature
0 14 0 this LLambdaTest;
}
SourceFile: "LambdaTest.java"
BootstrapMethods:
0: #70 REF_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:
#71 ()V
#74 REF_invokeSpecial LambdaTest.lambda$0:()V
#75 ()V
InnerClasses:
#22; // class LambdaTest$1
public static final #81= #77 of #79; // Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
NestMembers:
LambdaTest$1
첫번째 runnable객체의 경우에는 기존의 interface와 동일하게 invokespecial을 호출하여 초기화를 진행하고 invokeinterface를 통해 run을 실행한다. java7에서 interface를 다루는 방식과 동일하다. 하지만 lambda객체를 확인해보자, invokedynamic이라는 opcode를 호출하는데 이 opcode로 객체를 초기화한다는 차이점이 존재한다.
invokedynamic은 무엇일까? 해당 opcode는 원래 JVM기반의 JRuby, Jython, Groovy와 같은 동적 타입 언어(javascript같은?)를 지원하기 위해 만들어진 명령어 인데 간단하게 말하자면 컴파일 시점이 아닌 런타임시점에서 함수를 동적으로 생성하는 과정이라고 보면 된다.
invokedynamic이 호출되면 위와같이 bootstrap method를 호출하여 callsite라는 객체를 반환받고 이를 통해 람다함수를 호출하게 된다. 이 때 첫 bootstrap method호출에선 느릴수밖에 없다는 문제가 생긴다.(런타임에 함수를 생성하게 되니..) 하지만 한번 생성하고 나면 bootstrap method table에 등록되고 이를 바로 호출하는 식으로 성능관련 문제를 보완한 것으로 보인다.
import java.util.Arrays;
public class test {
public static void main(String[] args) throws Exception {
int[] arr = new int[500];
long beforeTime = System.currentTimeMillis();
Arrays.stream(arr).forEach(a -> {
System.out.println(a); //람다로 출력1
});
long afterTime = System.currentTimeMillis();
long secDiffTime = (afterTime - beforeTime);
System.out.println("시간차이(m) : " + secDiffTime);
beforeTime = System.currentTimeMillis();
Arrays.stream(arr).forEach(a -> {
System.out.println(a); //람다로 출력2
});
afterTime = System.currentTimeMillis();
secDiffTime = (afterTime - beforeTime);
System.out.println("시간차이(m) : " + secDiffTime);
beforeTime = System.currentTimeMillis();
for(int i = 0; i < 500; i++) {
System.out.println(arr[i]); //람다 없이 출력
}
afterTime = System.currentTimeMillis();
secDiffTime = (afterTime - beforeTime);
System.out.println("시간차이(m) : " + secDiffTime);
}
}
위 코드를 실행시켜 보다
...
시간차이(m) : 120
...
시간차이(m) : 96
...
시간차이(m) : 93
람다함수의 첫 실행에선 시간이 좀더 걸리는 것을 확인할 수 있지만 이후 다시 호출해보면 일반적인 for문과 실행시간이 비슷해지는 것을 볼수 있다. 물론 위 예제는 매우 단순한 예제로 복잡한 logic이 들어가게 되면 다양한 변수가 생길수 있다. 어찌 되었든 java의 lambda는 첫 호출에선 일반적인 함수에 비해 느려질 수 밖에 없는 구조이고 컴파일타임이 아닌 런타임으로 함수의 생성을 미룬 것으로 볼수 있다.
어찌됐든 처음 한번뿐이라곤 하나 성능에 상당히 손해를 보고 시작하는 묘한 방식이다. 그러면 다른 언어와 한번 람다를 다루는 방식을 비교해 보자. 다음은 C#에서 간단하게 람다를 생성해본 코드이다.
using System;
using System.Diagnostics;
namespace CsharpApp
{
class Program
{
static void Main(string[] args)
{
Func<int, int> twice = x => x * 2;
const int LOOP = 5000000; // 5M
var watch = Stopwatch.StartNew();
for (int i = 0; i < LOOP; i++)
{
twice.Invoke(3);
}
watch.Stop();
Console.WriteLine("Invoke: {0}ms", watch.ElapsedMilliseconds);
}
}
}
C#도 Java의 바이트코드와 비슷한 il code를 생성해주는데 C#의 경우 이전에 전처리를 한번 수행한다. 전처리를 수행한 코드만 봐도 Java의 방식과 차이를 확인할 수 있을 것이다.
using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security;
using System.Security.Permissions;
[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default | DebuggableAttribute.DebuggingModes.DisableOptimizations | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints | DebuggableAttribute.DebuggingModes.EnableEditAndContinue)]
[assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)]
[assembly: AssemblyVersion("0.0.0.0")]
[module: UnverifiableCode]
namespace CsharpApp
{
internal class Program
{
[Serializable]
[CompilerGenerated]
private sealed class <>c
{
public static readonly <>c <>9 = new <>c();
public static Func<int, int> <>9__0_0;
internal int <Main>b__0_0(int x)
{
return x * 2;
}
}
private static void Main(string[] args)
{
Func<int, int> func = <>c.<>9__0_0 ?? (<>c.<>9__0_0 = new Func<int, int>(<>c.<>9.<Main>b__0_0));
Stopwatch stopwatch = Stopwatch.StartNew();
int num = 0;
while (num < 5000000)
{
func(3);
num++;
}
stopwatch.Stop();
Console.WriteLine("Invoke: {0}ms", stopwatch.ElapsedMilliseconds);
}
}
}
위와 같이 컴파일타임에 class를 하나 생성하여 그 class의 내부함수를 호출하는 식으로 구현되어 있다. 용량은 좀더 커지겠지만(이걸 단점이라 하긴 뭐하지만..) Java와 같이 slow path는 생성되지 않을 것이다.
Java의 람다는 start시 느리다는 문제?, 특징?이 있는데 위에선 간단한 예제에서만 확인을 해보았는데 과연 복잡한 코드나 다중 thread 환경에선 Java의 람다가 어느정도 병목이 될지 나중에 한번 확인해 볼만한 문제일 것 같다.