1. 간단한 JVM의 메모리
프로세스와 스레드는 모두 CPU의 실행 단위이다. 각각의 메모리 공간과 실행의 흐름(조금 더 하드웨어적인 관점으로는 instruction, 프로그래밍 적으로는 메소드)을 가지고 프로그램의 로직을 수행한다. 프로세스는 프로그램 실행을 위해 필요한 일련의 모든 것들 즉, 힙과 스택 공간, 독립적인 파일 정보 등을 가진다. 그러나 스레드는 프로세스 내의 작은 실행 흐름(조금 더 자바적으로는 메소드)을 처리하기 위해 고유의 스택 영역과 pc를 가질 뿐이다.
JVM이 가진 메모리는 크게 스태틱 영역(메소드 영역이라고 부르는 곳도 있는듯), 힙 영역, 스택 영역으로 구분된다. 스태틱 영역엔 기본적으로 사용되는 자바랭 패키지 이외에 프로세스 진행 중 로드되는 클래스 정보, static이 붙은 변수 등이 적재된다. 상수 풀도 스태틱 영역에 존재한다. 힙 영역에는 프로그램에서 사용되는 객체 인스턴스가 적재된다. Reference type 변수가 가리키는 주소공간, 동시에 GC의 대상이 되는 공간이 바로 이 힙 영역이다. 그리고 스택 영역에는 메소드를 호출할 때의 지역변수, 매개변수, 메소드 호출 정보 등이 적재된다.
메소드가 호출되면 스택 영역에 메소드 실행에 필요한 정보들인 호출 정보, 매개 변수, 지역 변수 등을 담아 '스택 프레임'이 push되고, 메소드가 return되면 pop된다.
자바 프로세스 하나가 가지고 있는 메모리 공간이 위 그림과 같다. 그리고 프로세스 내의 스레드는 스택 프레임을 갖는다. 스레드에서 말하는 '실행 흐름'이 메소드의 개념으로 나타난 것이다. 스레드는 자신이 가진 고유한 스택 프레임을 제외한 나머지 메모리 공간(힙, 스태틱 영역)을 같은 프로세스의 다른 스레드들과 공유한다. 멀티스레드 환경에서 발생하는 대부분의 문제가 여기서 발생한다. synchronized 예약어와 관련된 자바 멀티스레드 문제는 링크 참조..
스레드간 공유하는 영역인 힙과 스태틱 영역을 제쳐두고, 이젠 스레드 고유한 값을 생각해보자. 각각의 스레드에 해당 스레드만이 이용할 수 있는 고유의 값을 저장하고 싶다. 이를 위해 ThreadLocal이라는 클래스를 사용한다. 해당 클래스의 설명 이전에, 어떻게 하면 스레드 고유의 값을 저장할 수 있을지 다른 방법들도 간단하게 생각해보자!
2. 스레드 독립적인 변수를 어떻게 만들까?
RandomNumber 클래스를 작성했다. generateRandomNumber()라는 스태틱 메소드는 1과 32 사이의 랜덤한 정수 값을 반환한다.
public class RandomNumber {
static Random random = new Random();
static Integer generateRandomNumber() {
return random.nextInt(32);
}
}
스레드에 generateRandomNumber() 메소드를 통해 생성된 값을 저장하고 싶다. 그리고 메소드를 호출한 스레드마다 고유한 랜덤 정수값을 가지게 하고 싶다. 이럴때 어떻게 해야할까?
1) 스레드 메소드 인자로 값을 전달
가장 간단하게 생각해볼 수 있는 방법은 스레드 내부에 클래스 필드를 추가한 후, 인자로 값을 전달해주는 방식이다. 실험을 위해 Thread를 확장한 MyThread1 클래스를 만들고, 스레드 고유의 값을 저장할 value 필드를 추가하자.
@Getter
public class MyThread1 extends Thread {
private final int value;
public MyThread1(int value) {
this.value = value;
}
@Override
public void run() {
System.out.println(this.getName() + "'s value is " + value);
}
}
value는 스레드마다 다른 값을 가진다고 말할 수 있다. 실제로 MyThread1 인스턴스를 만들고, generateRandomNumber()을 호출하여 저장한 뒤 결과를 확인해보자.
public class ByParameter {
public static void main(String[] args) {
MyThread1 thread0 = new MyThread1(RandomNumber.generateRandomNumber());
MyThread1 thread1 = new MyThread1(RandomNumber.generateRandomNumber());
thread0.start();
thread1.start();
}
}
쉽게 예상 가능한 결과 출력문은?
Thread-0's value is 31
Thread-1's value is 13
첫번째 스레드의 value값은 31, 두번째 스레드의 value값은 13이다. 스레드별로 잘 구분되었다.
위 방식은 직관적이지만 몇 가지 문제가 있다. 스레드 고유한 값을 저장하고 가져오기 위해 스레드 객체의 메소드를 직접 호출해야 한다. 만약 여러 목적으로 공유하는 변수가 수십 개라면 수십 개 변수에 맞는 getter, setter 등의 메소드를 하나하나 다 구현하고 사용해야 할 것이다.
2) ThreadLocal 사용
이번엔 ThreadLocal을 사용해보자. 같은 RandomNumber 클래스에 ThreadLocal 필드 하나를 추가했다. 그리고 setRandomNumber() 메소드를 통해, 직전에 추가한 ThreadLocal 필드에 특정 값을 지정했다.
public class RandomNumber {
static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
static Random random = new Random();
static Integer generateRandomNumber() {
return random.nextInt(32);
}
static void setRandomNumber() {
threadLocal.set(random.nextInt(32));
}
static void removeRandomNumber() {
threadLocal.remove();
}
}
그리고 새로운 Thread2 클래스를 아래와 같이 작성하자.
public class MyThread2 extends Thread {
@Override
public void run() {
RandomNumber.setRandomNumber();
System.out.println(this.getName() + "'s value is " + RandomNumber.threadLocal.get());
}
}
이번엔 클래스 변수인 value 필드값이 없다. RandomNumber 클래스에서 선언한 스태틱 메소드인 setRandomNumber()을 호출하여 ThreadLocal 필드에 임의 정수 값을 set한 뒤 해당 필드의 get() 메소드를 호출했다.
get() 메소드를 호출했을 때, 인자로 스레드의 정보를 넘겨주지 않았다. get() 메소드를 호출한 객체가 특정 스레드에 종속된 객체도 아니다. 그런데도 스레드마다 값이 구분이 될까? 멀티 스레드 환경에서 run() 메소드를 돌려보자.
public class ByThreadLocal {
public static void main(String[] args) {
MyThread2 thread0 = new MyThread2();
MyThread2 thread1 = new MyThread2();
thread0.start();
thread1.start();
}
}
결과는?
Thread-0's value is 26
Thread-1's value is 27
이로써 RandomNumber의 ThreadLocal타입 스태틱 필드인 threadLocal에는 스레드별로 다른 값이 담긴다는 것을 확인할 수 있었다.
3. ThreadLocal : Key(thread) - Value 구조
ThreadLocal 클래스의 get() 메소드를 살펴보자.
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
일단 getMap(t)를 통해 현재 스레드에 맞는 ThreadLocalMap 객체를 가져온다.
ThreadLocalMap은 'Entry'라는 이름의 해시 테이블을 갖는 클래스이다. Entry는 ThreadLocal 객체를 키로 가지는 해시 테이블이다.
현재 스레드의 해시 테이블을 가져와서 원하는 ThreadLocal 객체의 value 값을 가져오는 과정으로 이해하면 된다.
뭔가 복잡한데, 스레드를 키로 가지는 Map 자료구조(ThreadLocalMap)를 이용한다는 것이 중요하다.
4. 실제 사용
ThreadLocal은 스레드 독립적인 변수를 사용할 때 유용하게 쓰인다. 검색을 해보니 가장 대표적으로 Spring Security의 인증정보를 저장할 때 쓰인다고 한다.
실제로 Spring Security를 이용한 프로젝트에서, 아래와 같은 코드를 통해 사용자 인증정보를 가져온 적이 있다.
SecurityContextHolder.getContext().getAuthentication();
Spring에서 널리 사용하는 WAS인 톰캣은 사용자 요청에 따라 스레드를 생성하여 처리한다. 한 사람의 요청엔 한 사람의 인증정보가 들어있고, 이는 스레드마다 다른 값을 가진다.
final class ThreadLocalSecurityContextHolderStrategy implements
SecurityContextHolderStrategy {
private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();
public void clearContext() {
contextHolder.remove();
}
public SecurityContext getContext() {
SecurityContext ctx = contextHolder.get();
if (ctx == null) {
ctx = createEmptyContext();
contextHolder.set(ctx);
}
return ctx;
}
public void setContext(SecurityContext context) {
Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
contextHolder.set(context);
}
public SecurityContext createEmptyContext() {
return new SecurityContextImpl();
}
}
5. 주의할 점
1) 스레드 풀로 기존의 스레드를 재사용했을 때
자바에서 스레드를 새로 생성하는 것은 꽤나 비싼 작업이어서, 보통은 스레드 풀을 이용해 스레드를 미리 만들어두고 재사용하는 방법을 사용한다. 문제는 이런 스레드 풀과 ThreadLocal을 함께 이용했을 때 발생한다. 스레드 풀을 이용해 스레드를 재사용했을 때, 스레드 종료 전에 명시적으로 remove()를 호출하지 않았다면 ThreadLocal값은 기존 스레드의 값을 가진다.
일단 threadLocal 을 담을 MyNumber 인터페이스를 작성했다.
public interface MyNumber {
ThreadLocal<String> threadLocal = new ThreadLocal<>();
}
그리고 이용할 스레드 클래스를 작성했다.
public class MyThread extends Thread {
@Override
public void run() {
if (MyNumber.threadLocal.get()==null) {
System.out.println(getName() + "'s value is null!");
MyNumber.threadLocal.set(getName());
} else {
System.out.println(getName() + "'s value = " + MyNumber.threadLocal.get());
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
- 스레드의 ThreadLocal 값이 null이라면 null 경고문을 출력한 후 해당 스레드 이름을 ThreadLocal 값으로 지정한다.
- 스레드의 ThreadLocal 값이 있다면 해당 스레드의 ThreadLocal값을 출력한다.
- 그 뒤 0.1초동안 슬립한다.
스레드 풀을 이용해서 위 스레드를 동작시켜보자.
public class ByThreadPool {
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(5);
for (int i=0; i<10; i++) {
service.submit(new MyThread());
}
service.shutdown();
}
}
- 스레드 풀의 크기를 5로 지정했다. 5개의 고정된 스레드가 동작할 것이다.
- 스레드 풀을 이용해 10번의 작업을 한다. 5개의 스레드가 2번씩 동작(재사용)할 것이라고 쉽게 예상할 수 있다.
결과 출력문이 어떻게 나올까? set()을 통해 ThreadLocal 객체에 값을 저장하지 않았을 때 초기값은 null이므로, null 경고문이 10번 출력될 것이라고 예상이 간다. 하지만 결과는?
Thread-1's value is null!
Thread-2's value is null!
Thread-4's value is null!
Thread-3's value is null!
Thread-0's value is null!
Thread-7's value = Thread-0
Thread-9's value = Thread-2
Thread-8's value = Thread-4
Thread-5's value = Thread-3
Thread-6's value = Thread-1
가장 처음 실행된 Thread-0~Thread-4는 예상대로 null 경고문이 출력되었으나, 기존의 스레드가 재사용된 Thread-5~Thread-9는 재사용 전 스레드의 ThreadLocal값이 출력되었다!!
이는 의도와 다른 동작의 가능성이 있다. 해결을 위해 run() 메소드 내부의 가장 끝, 스레드가 종료되기 전 remove() 메소드를 호출해줘야 한다.
MyNumber.threadLocal.remove();
2) 메모리 누수 문제
톰캣 등의 WAS를 사용할 경우, WAS는 스레드 풀을 만들어두고 사용자 요청에 따라 스레드를 할당하게 된다.
바로 위의 예시에서 확인했듯, 일반적으로 스레드 풀의 스레드는 서버가 구동되고 있는 동안엔 계속 존재하며, 재사용된다. ThreadLocal에 특정 레퍼런스 타입의 객체를 저장한다 생각해보자. ThreadLocal 변수는 get()을 호출하는 스레드에 맞는 객체를 힙 영역에서 찾아 반환할 것이다. 그리고 일반적으로, 스레드가 종료되거나 스레드풀이 termiate되면 스레드가 참조하는 ThreadPool의 인스턴스 역시 사라질 것을 기대한다. (GC에 의해)
그러나 요청에 따른 웹서버 로직이 종료되더라도, 혹은 스레드 풀이 shutdown 되더라도 스레드 풀의 스레드는 프로그램 종료 시까지 남아있다. ThreadLocal 값들이 사용되지 않는 스레드들에게 계속 참조되고 있기 때문에, ThreadLocal이 가리키는 인스턴스는 계속 메모리에 존재한다. 스레드 풀을 shutdown할 때, remove()를 똑바로 시켜주지 않으면 이렇듯 사용하지 않는 객체가 메모리에 남아있으므로 메모리 누수를 일으키기 쉽다.
따라서 ThreadLocal 변수를 사용할 땐, 스레드 종료시 명시적으로 remove() 메소드를 호출하는 것이 좋다.
REFERENCE
https://sabarada.tistory.com/163
https://madplay.github.io/post/java-threadlocal
https://stackoverflow.com/questions/17968803/threadlocal-memory-leak