📂 목차
📚 본문
Wrapper
primitive 타입을 reference 타입으로 바꾸는 클래스이다.
이 행위를 boxing 이라고 하며, Java 5 부터 boxing, unboxing 을 통해 자동으로 형변환을 해준다.
메모리 및 참조 측면
primitive
는 스택(Stack
) 메모리에 값 자체가 저장됨wrapper
객체는 힙(Heap
) 메모리에 생성되고, 변수에는 그 객체의 참조(reference)가 저장됨- 따라서 wrapper 는 객체이므로
null
값을 가질 수 있고, 제너릭/컬렉션에서 객체로 다룰 수 있음 - 하지만 객체 생성/참조 때문에
primitive
에 비해 메모리 사용량과 성능 오버헤드가 있음
캐싱 최적화
캐싱 최적화는 Wrapper 클래스가 불필요하게 객체를 계속 생성하지 않도록 자주 쓰이는 값을 미리 캐싱해두는 것을 말하며, 다음 값들을 캐싱한다.
Byte
,Short
,Integer
,Long
: -128 - 127 범위를 캐싱Character
: 0 - 127 캐싱Boolean
:true
,false
두 값만 캐싱Double
,Float
: 캐싱 없음
Record
Java 14 에 도입된 기능이며, Java 16 부터 정식으로 사용하게 된다. 데이터 전달 객체(VO, DTO) 등에 보일러 플레이트 코드를 줄여주는 문법이며, 다음 메서드를 자동으로 생성한다:
equals()
hashCode()
toString()
예시
public record Book(long id, String title, String author, boolean isRented) { }
불변이기에 상속이 불가능하며, JPA 엔티티와 같은 mutable 한 객체에는 적합하지 않다.
Generic
Java 의 제너릭(Generic)은 클래스나 메서드가 사용할 타입을 외부에서 지정할 수 있도록 하는 기능이다.
즉, 코드 재사용성과 타입 안전성을 높여준다.
장점
- 타입 안정성 보장: 잘못된 타입을 넣으면 컴파일 타임에 에러 발생
- 형변환(
Casting
) 불필요 - 코드 재사용성 증가
제너릭은 기본적으로 reference 타입이 들어가야 한다.
기본 문법
// 제너릭 클래스
public class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
// 사용
Box<String> box = new Box<>();
box.set("Hello");
String str = box.get(); // 형변환 필요 없음
Generic Method
특정 타입들에 대한 함수를 만들고 싶을때 선언할 수 있다. 이는 클래스 scope 에 선언된 제너릭 변수를 들고와도 되고, 쓰고 싶은 제너릭 변수를 함수 앞쪽에 선언해주어도 된다.
// void 앞에 쓸 Generic 을 선언
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}
Bounded Type
모든 타입을 참조하여서 쓰는 것은 바람직하지 않다. 심지어 모든 타입을 그대로 그 함수가 받아서 올바른 행위를 수행할 수 있는 코드를 짤 수 있을지도 의문일 것이다.
이를 제한시켜 사용할 수 있는 예약어가 extends
, super
이다.
extends 키워드
extends
는 특정 타입과 그 하위 타입만 허용하도록 제한할 수 있다.- 주로 메서드의 파라미터 또는 제너릭 타입 선언에서 사용된다.
// Number 와 그 하위 타입만 받을 수 있음
public static <T extends Number> void showNumber(T num) {
System.out.println(num.doubleValue());
}
super 키워드
super
는 반대로 하위 타입까지 허용할 수 있도록 한다.- 주로 메서드의 파라미터에서 사용된다.
// Number 와 그 상위 타입을 받을 수 있음
public static void addNumber(List<? super Integer> list) {
list.add(10); // Integer 추가 가능
// list.get(0); // Object 로만 꺼낼 수 있음
}
제너릭 타입 파라미터 vs 와일드카드(?)
T, E, K, V 등
- 제너릭 클래스나 메서드에서 타입을 선언할 때 주로 사용
- 의미를 명확히 나타내는 이름으로 바꿔서 사용 가능
- T: Type
- E: Element (컬렉션 요소)
- K: Key
- V: Value
- (굳이 정해진 알파벳은 없고, 대문자에 한 문자로 쓰는게 관례이며, ID 이렇게 써도 된다.)
예시
public class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
? (와일드카드)
- 특정 타입에 제한을 두지 않고 모든 타입을 참조할 수 있도록 할 때 사용
- 주로 메서드 파라미터에서 유연성을 주기 위해 사용
public void printList(List<?> list) {
for (Object obj : list) {
System.out.println(obj);
}
}
차이 요약
T, E
등: 제너릭 타입 변수, 실제 타입이 지정될 때 구체적으로 결정됨?
: 와일드카드, 메서드 호출 시점까지 정확한 타입을 알 수 없음,
읽기 전용에 주로 사용
Java Memory Model(JMM)
Java Memory Model(JMM)은 Java에서 멀티스레드 환경에서 메모리 접근과 가시성, 순서 문제를 정의한 규격이다.
즉,
- CPU 캐시, 레지스터, 메인 메모리 간의 동기화 문제를 정의
- 스레드 간 Visibility 와 Ordering 을 보장하도록 설계한다.
여기서 visibility 란 한 스레드에서 값이 바뀌어도 다른 스레드의 값이 바로 보장되지 않을 수 있는데,
이런 성질을 지키는 것이 바로 visibility 이다.
Ordering
여기서 왜 Visibility 가 깨질 수 있는지를 들여다 보자.
컴파일러와 CPU는 명령 재정렬(Instruction Reordering)이 가능한데,
이는 명령을 수행하는데 있어 최적화 되는 방향으로 JVM 이 실행 명령을 조정하는 행위이다.
이로써 코드 수행을 더 빠르게 할 수 있는데, 문제는
멀티 스레드 환경에서 의도치 않은 순서로 실행될 수 있다는 것이다.
예시
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // ①
flag = true; // ②
}
public void reader() {
if (flag) { // ③
System.out.println(a); // ④
}
}
}
public class Main {
public static void main(String[] args) {
ReorderExample obj = new ReorderExample();
Thread t1 = new Thread(obj::writer);
Thread t2 = new Thread(obj::reader);
t1.start();
t2.start();
}
}
위의 예시를 보면 처음에는 0이라는 값이 등장 할 것으로 예상이 되지만,
실제로 파보면 2번의 수행이 우선으로 재정렬되어 1이라는 값이 먼저 찍힐 수 있다는 것이다.
이렇게 되면 visibility
는 무너지게 되며 우리가 코드를 보는 직관이 무너지게 된다.
Atomicity
스레드 간 공유 변수가 있어 연산에서 중간에 끼어들어 깨질 수 있는 문제가 발견된다(위 예시처럼).
예시
count++ // read + increment + write 3단계
실제로 위 같은 코드를 사용하면 더 깨지기 쉬울 것이며,
이렇게 연산 도중 다른 스레드가 끼어들어서 값 보장을 망치는 행위를 Atomicity
가 깨졌다고 한다.
이를 방지하기 위해 Java 는 Atomic
클래스와 Volatile
키워드 들을 제공하게 된다.
Atomic Class
Java 에는 java.util.concurrent
패키지에 다양한 동시성 제어 기능들을 넣어놓았다.
그 중에 AtomicXXXX
의 클래스 류들은 위와 같은 원자성을 보장하기 위해 사용된다.
그 안에는 Lock
이라는 기능을 통해 Lock 을 얻은 스레드 만이 자원에 대해 접근할 수 있도록 한다.
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicExample {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // atomic operation
}
public int getCount() {
return count.get();
}
public static void main(String[] args) throws InterruptedException {
AtomicExample example = new AtomicExample();
Thread t1 = new Thread(() -> {
for(int i=0; i<1000; i++) example.increment();
});
Thread t2 = new Thread(() -> {
for(int i=0; i<1000; i++) example.increment();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + example.getCount());
}
}
Volatile
Atomic Class
외에도 자바에는 순서 보장과 값 갱신(Visibility)을 위해
변수에 대해 volatile
을 선언하여 최신 값만 읽도록 할 수 있다.
이는 변수에 대한 락을 구현하여 사용되어지며, 변수 타입 전에 volatile
을 써서 사용한다.
volatile boolean flag = false;
위 보다는 더 쉬울 것이다.
동기화
동기화라는 것은 스레드 간의 Mutual Exclusion
를 구현하는 것이다.
위 값들은 전부 변수에 대한 접근에 있어서 변수 스코프에 대해 안전한 접근을 수행하도록 하였다.
하지만, 이는 단일 연산 단위에서만 안전하게 된다.
예시
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicExample {
private AtomicInteger count = new AtomicInteger(0);
public void incrementTwice() {
count.incrementAndGet(); // 원자적 연산
count.incrementAndGet(); // 원자적 연산
}
public int getCount() {
return count.get();
}
public static void main(String[] args) throws InterruptedException {
AtomicExample example = new AtomicExample();
Thread t1 = new Thread(example::incrementTwice);
Thread t2 = new Thread(example::incrementTwice);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(example.getCount());
}
}
위의 count 라는 것은 AtomicClass
로 원자성을 지닌 변수이다.
이 값에 대한 연산들은 전부 원자적이며, 연산 수행 도중에는 아무도 끼어들 수 없을 것이다.
하지만 이 연산을 두 번 수행하는 incrementTwice
에 대해서 getCount()
를 한다면
직관적으로 봤을 때 우리의 눈에는 count.get()
에는 2의 배수만 찍혀야 하지만, 실제로 홀수가 찍힐 수 있다.
이를 동기화가 안되었다고 한다. 즉, 스레드끼리 연산을 스레드의 다중 연산 코드를 수행함에 있어
원자적 연산을 연속으로 수행하다가 다른 스레드가 이를 침범하여 수행할 수 있다는 것이며,
서로 스레드 끼리의 동기화가 되지 않았다는 것이다.
이를 보장하기 위해 synchronized
라는 기능이 추가된다.
Synchronized
상호 배제와 락을 구현한 대표적인 키워드이며, Atomicity
문제를 해결하기 위한 가장 대표적인 방법이다.
메서드나 블록 단위(코드 블록 말하는 것, 중괄호)로 스레드 간 상호 배제의 원칙을 지키며 코드를 처리한다. 보통 return type 이전에 synchronized
를 붙여 해당 함수는 쓰레드 A 가 들어왔을 때 해당 함수에 대한 Lock
을 얻어 다른 함수가 Lock
을 못 얻게(permission 을 못 얻게) 하여 thread-safety 를 얻게 된다.
private static synchronized <T extends List> T func(T t) {
...
}
synchronized 는 락, 상호 배제를 둘 다 구현했기에
이를 적용한다면 thread-safety 하다고 할 수 있다.
Synchronized vs Volatile 적용 위치
키워드 | 적용 가능 위치 | 설명 |
---|---|---|
synchronized | 메서드, 블록 | - 메서드에 붙이면 해당 메서드 전체가 락을 획득 - 블록에 붙이면 특정 코드 블록에 대해서만 락 획득 - 함수나 객체 단위로 다중 연산 thread-safe 구현 가능 |
volatile | 변수(필드) | - 멤버 변수에만 사용 가능 - 읽기/쓰기 시 메인 메모리에서 직접 접근 보장( Visibility ) - 단일 변수 단위로 최신 값 유지, atomic 연산 보장 X |