굉장히 많지만.. 개인적으로 중요하다고 생각하는 특징은 1) OOP 2) 플랫폼 독립적 3) GC 세 가지이다.
자바는 OOP를 가장 잘 활용할 수 있는 언어이고,
platform-dependent의 WORA(Write Once, Run Anywhere)의 철학으로 자바가 탄생 하였고,
또한, GC는 자바 성능 관련 대규모 애플리케이션에서 빼놓을 수 없는 키워드이기 때문입니다.
객체지향 프로그래밍의 4가지 특징
1) 추상화: 객체의 공통적인 속성과 기능을 뽑아 정의하는 것 - abstract class, interface
2) 상속: 기존 클래스의 기능을 사용하면서도 확장해서 사용하는 것. 부모 클래스의 모든 기능을 물려받는 자식 클래스를 정의하는 것. (단, 생각보다 상속은 꽤 위험하다.. why?)
3) 다형성: 객체지향 프로그래밍의 꽃! 핵심!
구현 클래스에 직접 의존하는 것이 아니라, 상위 클래스(인터페이스)에 의존하여 객체 간 의존도를 낮출 때 자주 사용한다. 확장에 열려있고, 변경에 닫혀있는 OCP 원칙도 지킬 수 있다. 또한, 구현체만 갈아끼우면 되기 때문에 테스트에도 용이하다.
4) 캡슐화: 외부로부터의 접근을 보호하고, 필요한 것만 외부에 노출시키는 것. (ex. 접근제어자, getter/setter)
참고: 자바 컴파일러(javac)에 의해 컴파일된 바이트 코드(.class)는 JVM 실행엔진의 인터프리터 + JIT 컴파일러 과정을 통해 기계어로 변환
자바 소스코드(.java) -> [javac 자바 컴파일러] -> 바이트코드(.class) -> [JVM] -> 기계어
Class Loader가 클래스들을 동적으로 로딩해서, Runtime Data Area에 배치한다.
로딩 -> 링킹 -> 초기화
1. Loading: 바이트코드를 JVM의 메모리로 동적 로드
2. Linking: 레퍼런스 연결
JVM이 OS에게 할당받은 메모리 영역이다.
1. 공유 영역
Runtime Data Area 영역에 배치된 바이트코드를 명령어 단위로 읽어서 실행한다.
JVM은 바이트코드를 읽어 기계어로 변환하는데, 이때 인터프리터와 JIT 컴파일러 두 가지 방식을 혼합하여 바이트 코드를 실행한다.
참고: https://velog.io/@impala/JAVA-JVM-Class-Loader
JVM은 가비지 컬렉터가 Heap 영역에 더 이상 참조되지 않은 메모리를 자동으로 회수한다.
참조되지 않은지는 어떻게 판단하냐면? Mark and Sweep.
1. Young Generation
Eden, Survival 0, Survival 1 영역으로 나누어져 있다.
<Minor GC 과정>
1. Eden 영역이 가득 차면 minor GC 발생!
2. reachable한 객체를 찾아서, 한 곳의 Survivor 영역으로 이동시킨다. (Mark)
3. unreachable한 객체를 메모리에서 해제한다. (Sweep)
4. 살아남은 객체는 age가 1씩 증가.
1~4 과정 반복.. 그러다 minor GC로부터 살아남은 객체 중 age가 임계값(31)을 넘으면, Old Generation으로 넘어간다. (Promotion이라 불림)
2. Old Generation
Old Generation 영역도 부족하면 Major GC(=Full GC) 발생!
큰 공간을 가지고 있어서 오래 걸린다. Full GC가 일어나는 동안은 애플리케이션이 멈추기 때문에 CPU 부하가 가고, 멈추는 등 장애가 발생할 수 있다.
-XX:+UseSerialGC
-XX:+UseParallelGC -XX:ParallelGCThreads=N
-XX:+UseParallelOldGC -XX:ParallelGCThreads=N
-XX:+UseG1GC
-XX:+UseShenandoahGC
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
참고
자바 소스코드를 자바 컴파일러에 의해 컴파일된 중간 단계의 코드입니다. JVM이 이 바이트코드를 해석하여 애플리케이션이 구동됩니다.
JVM의 Class Loader가 바이트코드를 Runtime Data Area 영역에 배치시키면, Execution Engine이 해당 바이트 코드를 기계어로 해석하여 구동합니다. 이때, 바이트코드를 한 줄씩 실행하는 인터프리터 방식과 동적으로 최적화하여 기계어로 컴파일하여 캐싱해두는 JIT 컴파일 방식 두 가지로 진행됩니다.
Q. JIT 컴파일러에 의해 컴파일된 네이티브 코드는 코드 캐시라는 메모리 영역에 저장된다고 하셨는데요, 만약 여기가 꽉 찰 경우 어떻게 될까요?
A. 코드 캐시 영역은 JVM이 시작될 때 설정된 크기로 고정되므로 확장이 불가능합니다. 따라서 코드캐시가 꽉 차면 더이상 JIT 컴파일은 이루어지지 않고 새로운 코드는 모두 인터프리터 모드로만 실행됩니다.
즉, 남은 코드가 모두 인터프리터로 동작되므로 성능 저하가 발생할 수 있습니다.
<추가>
-XX:ReservedCodeCacheSize= 옵션을 통해 코드 캐시의 최대 크기를 지정할 수 있기 때문에 적절히 크기를 관리하는 것이 중요.
참고: https://velog.io/@ddangle/Java-JIT-%EC%BB%B4%ED%8C%8C%EC%9D%BC%EB%9F%AC-sfbp9dtu
Runnable - void run(): 매개변수 X, 리턴 X 하지 않을 때 사용
Consumer<T> - void accept(T t): 매개변수 O, 리턴 X 하지 않을 때 사용
Supplier<T> - T get(): 매개변수 X, 리턴 O 할 때 사용
Function<T, R> - R apply(T t): 매개값을 매핑(=타입변환. T->R)해서 리턴할 때 사용
Predicate<T> - boolean test(T t): 매개값이 조건에 맞는지 단정해서 boolean 리턴
Operator - R applyAs(T t): 매개값을 연산해서 결과 리턴
forEach
의 경우, 내부적으로 for문을 수행한다.ParallelStream
Work-Stealing 메커니즘
: 스레드의 task가 없으면 다른 스레드의 task를 steal해와서 처리해서 CPU 자원 낭비없이 최적의 성능 발휘.Stream<Integer> 대신 IntStream 사용하면 됨
)Optional<T>
는 null이 올 수 있는 값을 감싸는 Wrapper 클래스. 즉, null이 들어간 객체 접근시 NPE가 발생했지만, Optional로 감싸면 NPE가 바로 발생하지 않는다.CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
System.out.println("Thread " + Thread.currentThread().getName());
});
future.get();
public sealed interface CarBrand permits Hyundai, Kia {}
public final class Hyundai implements CarBrand {}
public non-sealed class Kia implements CarBrand {}
JDK(Java Development Kit)는 개발에 필요한 라이브러리와 javac, debugger 등의 개발 툴이 포함되어 있는 sdk입니다. 또한, JRE까지 포함되어 있습니다.
JRE(Java Runtime Environment)는 자바 프로그램을 실행시킬 때 필요한 라이브러리와 JVM이 묶여 있는 패키지입니다.
간단히 Java로 프로그램을 개발하려면 jdk가 필요하고, 컴파일된 Java 프로그램을 실행시키려면 JRE가 필요합니다.
동일성은 두 객체가 물리적으로 가리키는 메모리 주소 값이 같다는 것을 의미합니다. 이때, ==
를 통해 검증합니다. 메모리 주소가 같다면 true, 다르면 false를 리턴합니다.
동등성은 두 객체가 논리적으로 동등한 경우를 의미합니다. 이때 equals()
와 hashCode()
를 통해 검증합니다. equals()
는 기본적으로 Object에 ==
로 정의되어 있지만, 언제든지 논리적 상황에 따라 equals()를 재정의할 수 있습니다.
equals()
, hashCode()
HashCode는 객체의 메모리 주소값을 정수로 변환한 고유한 값을 의미합니다. 따라서, 두 객체가 동일한지 판단할 때 사용할 수 있습니다.
equals()는 두 객체가 논리적으로 동등한지 판단할 때 사용하고, 리턴값이 boolean 입니다.
equals()
와 hashCode()
를 같이 재정의해야 하는 이유?객체에 equals()
를 재정의했다 해도 객체를 HashTable의 Key로 사용하려는 경우, 문제가 발생할 수 있습니다.
예를 들어, 클래스 내 필드값이 같으면 같은 객체로 간주하도록 equals()만 재정의했다고 가정해보겠습니다.
동일한 필드값으로 생성한 인스턴스 두 개를 HashSet에 추가하면, 중복을 허용하지 않는 HashSet의 특성상 하나만 저장될 것으로 예상하겠지만, 실제로는 두 개 모두 저장됩니다.
이는 HashSet이 내부적으로 HashMap으로 구현되어 있어, 키값(객체)의 해시코드를 통해 접근하기 때문입니다. 두 객체의 해시코드가 다르므로 서로 다른 객체로 인식되는 것입니다. 따라서, 논리적 동등성을 보장하기 위해서는 equals()와 hashCode() 모두를 재정의해야 합니다.
기본적으로 Object의 toString()
은 클래스이름@주소값
이지만, 객체 주소값은 사실 개발하면서 크게 확인할 일이 없기에, 재정의해서 디버깅에 사용합니다. 또한, print 출력 메소드 사용시에 자동으로 toString() 값으로 출력됩니다.
비슷한 메소드로 String.valueOf()
가 존재. 내부적으로 toString()
을 호출하지만 NPE가 일어나지 않습니다. "null"
로 출력.
main이 시작점이기 때문에, 클래스 인스턴스를 생성하지 않고 곧바로 호출 가능하도록 되어 있습니다. 또한, GC에 의해서 메모리에서 해제되는 것을 방지해줍니다.
시그니처: public static void main(String[] args)
상수는 변경 불가한 데이터를 의미합니다. final 키워드로 선언하고, 의미있는 문자로 네이밍해야 하고 대문자와 언더스코어 규칙을 따릅니다.
리터럴은 소스코드에 직접 표현되는 상수 값입니다.
long a = 100L;
double d = 0.11d;
d는 생략 가능float f = 0.11f;
f는 생략 불가'\\'
(백슬래시), '\t'
(탭), '\n'
(라인피드) 등String literal = "literal";
boolean a = true;
boolean b = 1 > 0;
Integer c = null;
primitive type은 정수, 실수, 문자, 불리언 값을 직접 저장하는 타입입니다.
byte, short, int, long, float, double, char, boolean
Stack 영역에 저장되고, 메모리 사용과 접근속도에서 유리합니다. null이 불가능하고 모두 default값을 가집니다. 제너릭 타입에서 사용이 불가능합니다.
reference type은 객체의 메모리 주소를 저장하는 타입입니다.
Heap 영역에 저장되고, null이 존재합니다.
primitive에 대응되는 wrapper class가 있기 때문에 서로 형변환이 가능한데, 이걸 자동으로 처리하는 기능인 오토박싱/오토언박싱을 제공합니다. 박싱(long->Long), 언박싱(Long->long)
하지만 cpu 사용량과 메모리 사용량을 증가시키는 성능 저하 여지가 있습니다. 특히, 반복문을 통해 많이 발생한다면 위험하기 때문에, 람다를 사용할 때 IntStream과 같은 스트림 API를 잘 활용하는 것이 중요합니다.
개인적으로 null이 꼭 필요하지 않은 경우, primitive type을 사용해서 성능을 높이는 것이 좋다고 생각합니다. 예를 들어, int의 경우 0과 null을 꼭 다르게 처리해야 하는 게 아니면 primitive 사용 권장 !
자바에서는 모든 것이 Call by Value로 동작합니다. primitive type의 경우 실제 값이 복사되어 전달되고, reference type의 경우 객체의 참조값(메모리 주소)이 복사되어 전달되기 때문입니다.
Q: 자바에서도
int[] arr
를 받아서,arr[0] = 1
을 실행하면 원본 데이터가 수정되던데.. Call by Reference가 아닌가요?
A: Java에서는 모든 것이 값으로 전달됩니다. 메소드 내에서 참조를 통해 객체의 내용을 변경하면 원본 데이터에 영향을 줍니다. 그러나 매개변수에 새로운 객체를 할당해도 원본 데이터는 변경되지 않습니다. (내부 값 수정은 가능하지만, 변수 자체를 변경할 수는 없다.)
자바에서 사용되는 객체를 외부에 내보내서 다른 자바 시스템에서 사용할 수 있도록 최소 단위인 바이트스트림 형태로 연속적인 데이터로 변환하는 포맷 변환 기술입니다.
그 반대인 역직렬화(Deserialization)은 바이트스트림 형태를 자바 Object로 변환하는 기술입니다.
하지만, 자바 직렬화의 문제점이 많습니다.. 따라서 Java 객체와 Json 간 변환을 하는 ser/deser로 많이 사용합니다. Spring MVC에서는 MappingJackson2HttpMessageConverter
를 사용하여 HTTP 요청과 응답에서 JSON 데이터를 처리합니다.
private static final long seralVersionUID = 10L
// 직렬화할 객체 정의
import java.io.Serializable;
class A implements Serializable {
// fields..
}
// Serialization 과정
A a = new A();
// 필드 setter..
try (
FileOutputStream fos = new FileOutputStream("sample.csv");
ObjectOutputStream out = new ObjectOutputStream(fos)
) {
out.writeObject(a); // 바이트스트림으로 변환하고 파일에 저장
} catch (IOException e) {}
// Deserialization 과정
try(
FileInputStream fis = new FileInputStream("sample.csv");
ObjectInputStream in = new ObjectInputStream(fis)
) {
A a = (A) in.readObject(); // 바이트 스트림을 자바 객체로 변환
} catch (IOException | ClassNotFoundException e) {}
오버로딩은 메소드 이름은 같되 매개변수 타입과 개수가 다르게 메소드를 추가하는 것을 의미합니다. 메소드 리턴 타입이 다른 것은 불가합니다.
오버라이딩은 상위 클래스의 메소드를 하위 클래스가 재정의해서 사용하는 것을 의미합니다.
하나의 객체가 여러 가지 타입을 가질 수 있는 것을 말합니다. 이를 잘 활용하면 유연하고 확장성 있는 코드를 만들 수 있기 때문입니다.
예를 들어, 인터페이스 타입으로만 바라보도록 하면 내부 구현체가 바뀌어도 코드를 수정하지 않아도 되는 OCP 원칙을 잘 지킬 수 있게 됩니다. 특히, Spring에서는 이러한 다형성을 잘 활용할 수 있도록 제공해줍니다. DI 컨테이너와 빈 설정클래스를 통해서 구현체를 지정하여, 비즈니스 로직에서는 구현체에 신경쓰지 않아도 됩니다.
기존 클래스에서 사용되는 기능과 요소를 그대로 물려 받으면서(재사용), 확장해서 사용하는 것을 의미합니다.
강하게 결합되어 있기 때문에, 상위 클래스에 따라 하위 클래스에 이상이 생길 수 있습니다. 따라서, 상속을 하려면 상위 클래스의 내부 구현을 잘 알아야 합니다. 잘 모르고 사용하다가 예상치 못한 버그를 발생시킬 수 있기 때문입니다. 이러한 관점에서 내부 동작을 몰라도 되는 캡슐화를 망가뜨릴 수 있습니다.
조합(Composition)은 기존 클래스를 확장하지 않고, private 내부 필드로 사용하는 방식입니다. 이를 통해 강한 결합에서 약한 결합으로 바뀌게 됩니다. 기존 클래스에서 필요한 메소드들만 사용하여, 상위 클래스의 캡슐화도 잘 유지할 수 있습니다.
판단하고자 하는 객체가 특정 클래스의 타입인지를 판단하는 키워드입니다. 이때, 정확히 자기의 타입과 일치하지 않아도 상위클래스인 경우(즉, 형변환이 가능한 경우)에는 true를 리턴합니다.
일부러 인스턴스 혹은 추상 클래스로 추상화 및 캡슐화를 적용했는데, instanceof를 사용함으로써 클라이언트가 하위 클래스를 직접 사용하여 특정 클래스일 경우에만 처리하는 로직이 들어가 위 두 가지 원칙이 깨지게 됩니다.
새로운 구현체가 생기면, instanceof로 비교하는 곳에도 추가해줘야 할 수 있습니다. (OCP 위반)
불필요한 instanceof 검사로 성능이 저하될 수 있습니다.
따라서, instanceof 대신 boolean 리턴타입의 추상 메소드를 사용해서 특정 클래스에서만 구현을 다르게 동작시키거나 마커 인터페이스를 이용하면 됩니다.
마커 인터페이스: 아무런 메소드도 선언하지 않은 빈 인터페이스. 이를 활용해 추상메소드의 (대표적인 예: Serializable, Cloneable
)
참고: https://tecoble.techcourse.co.kr/post/2021-04-26-instanceof/
구현 스펙과 같이 기본 구조를 제공하고, 인터페이스를 매개체로 구현 클래스들 간 통신이 이루어집니다. 그 자체로 인스턴스를 생성할 수 없습니다. 또한, 사용하는 입장에서는 기본 구조(매개변수, 리턴타입)만 맞춰 요청하면 되므로, 내부 구현에 대해 신경쓰지 않아도 됩니다. 접근제어자는 모두 public이라는 특징이 있습니다.
원래는 상수와 추상메소드만 제공되었지만, java8부터 디폴트메소드와 정적메소드가 추가되었습니다.
ArrayList, HashSet, HashMap
등) 이때, 인터페이스에 디폴트 메소드를 추가해서 쉽게 해결한 것이다. 따라서 구현체 수정 없이 모두 적용시키고 싶을 때 사용하면 좋다.인터페이스는 다중 상속이 되고, 추상 클래스는 불가합니다.
추상클래스는 생성자를 가질 수 있지만, 인터페이스는 불가합니다.
인터페이스는 필요에 따라 여러 기능을 자유롭게 붙였다 뗐다 할 수 있기에 클래스끼리 관련성이 크게 없을 때, 추상클래스는 설계 단계에서 미리 상속 구조가 확실할 때 사용하면 좋습니다.
인터페이스 (implements)
추상 클래스 (extends)
변수, 메소드, 클래스에 대한 변경을 금지하는 키워드입니다. 이를 잘 활용하면 코드의 안정성을 높일 수 있습니다.
변수에 final을 사용하면, 상수가 되어 예상치 못한 값의 변경을 막을 수 있습니다.
메소드에 final을 사용하면, 오버라이딩이 불가하여 설계 의도를 명확하게 전달할 수 있습니다.
클래스에 final을 사용하면, 상속이 불가하여 확장이나 변경이 불가능합니다. 자바의 String
클래스도 final로 선언되어 있습니다.
특히, 상수를 사용할때는 static final을 함께 활용합니다. 프로그램 전체에서 공유되고, 변경 불가하기 때문에 핵심 상수를 정의할 때 사용하면 좋습니다.