Thread
자바 프로그램을 실행 후 적어도 하나의 JVM이 시작된다.
보통 JVM이 시작되면 자바 프로세스(Process)가 시작한다. 프로세스 안에서 여러 개의 쓰레드가 존재하고 있다.
우리가 Java 클래스를 실행하는 순간 자바 프로세스가 시작되고, main() 메소드가 수행되면서 하나의 쓰레드가 시작되는 것이다.
만약 많은 쓰레드가 필요하다면, main() 메소드에서 쓰레드를 생성해 주면 된다. 자바를 사용하여 웹을 제공할 때에는 Tomcat과 같은 WAS(Web Application Server)를 사용한다. 이 WAS도 똑같이 main() 메소드에서 생성한 쓰레드들이 수행되는 것이다.
- 쓰레드라는 것을 왜 만들었을까?
프로세스가 하나 시작하려면 많은 자원(resource)이 필요하다.
만약 하나의 작업을 동시에 수행하려고 할 때 여러 개의 프로세스를 띄워서 실행하면 각각 메모리를 할당하여 주어야만 한다.
JVM은 기본적으로 아무런 옵션 없이 실행하면 OS마다 다르지만, 적어도 32MB ~ 64MB(Mega byte)
의 물리 메모리를 점유한다.
반면에 쓰레드를 하나 추가하면 1MB 이내의 메모리를 점유한다. 따라서, 쓰레드를 “경량 프로세스(lightweight process)”
라고 부른다.
Tip. Thread 번역
Thread라는 단어의 사전적인 의미는 실타래를 의미한다고한다.
Runnable 인터페이스와 Thread 클래스
쓰레드 생성하는 방법
- Runnable 인터페이스를 사용하는 법
- Thread 클래스를 사용하는 방법
Runnable 인터페이스와 Thread 클래스는 모두 java.lang
패키지에 있다.
Runnable 인터페이스에 선언되어 있는 메소드
리턴 타입 | 메소드 이름 및 매개 변수 | 설명 |
---|---|---|
void | run() | 쓰레드가 시작되면 수행되는 메소드 |
- Runnable 인터페이스를 구현한 클래스
public class RunnableSample implements Runnable{
public void run(){
System.out.println("This is RunnableSample's run() method.");
}
}
- Thread 클래스 확장한 예제
public class ThreadSample extends Thread{
public void run(){
System.out.println("This is ThreadSample's run() method.");
}
}
- 쓰레드를 수행하는 예제 - RunThreads 클래스
public class RunThreads {
public static void main(String[] args) {
RunThreads threads = new RunThreads();
threads.runBasic();
}
public void runBasic() {
RunnableSample runnable = new RunnableSample();
new Thread(runnable).start();
ThreadSample thread = new ThreadSample();
thread.start();
System.out.println("RunThreads.runBasic() method is ended.");
}
}
- 위의 코드에서 아래의 두 줄이 중요하다.
- 쓰레드가 수행되는 우리가 구현하는 메소드는 run() 메소드다. →
new Thread(runnable).start();
- 쓰레드를 시작하는 메소드는 start()이다. →
thread.start();
- 쓰레드가 수행되는 우리가 구현하는 메소드는 run() 메소드다. →
Rnnable 인터페이스를 구현하거나 Thread 클래스를 확장할 때에는 run() 메소드를 시작점으로 작성해야만 한다.
그런데
쓰레드를 시작하는 메소드는 run()
이 아닌 start()
라는 메소드다.
내가 start()
메소드를 만들지 않아도, 알아서 자바에서 run()
메소드를 수행하도록 되어 있다.
- RunnableSample을 시작한 코드
- Runnable 인터페이스를 구현한 RunnableSample 클래스를 쓰레드로 바로 시작할 수 없다. 따라서, 이와같이 Thread 클래스의 생성자에 해당 객체를 추가하여 시작해 주어야만 한다.
new Thread(runnable).start();
- ThreadSample을 시작한 클래스
- ThreadSample 클래스의 객체에 바로 start() 메소드를 호출할 수 있다.
thread.start();
쓰레드라는 것을 start() 메소드를 통해서 시작했다는 것은, 프로세스가 아닌 하나의 쓰레드를 JVM에 추가하여 실행한다는 것이다.
그림처럼 쓰레드를 구현할 때, start() 메소드를 호출하면 쓰레드 클래스에 있는 run() 메소드의 내용이 종료되든 안되든 쓰레드를 시작한 메소드에서는 그 다음 줄에 있는 코드를 실행한다.
RunnableSample
,ThreadSample
각 5개씩 객체를 생성하여 실행한 예제
public class RunMultiThreads {
public static void main(String[] args) {
RunMultiThreads sample = new RunMultiThreads();
sample.runMultiThread();
}
public void runMultiThread() {
RunnableSample []runnable = new RunnableSample[5];
ThreadSample []thread = new ThreadSample[5];
for (int loop = 0; loop < 5; loop++) {
runnable[loop] = new RunnableSample();
thread[loop] = new ThreadSample();
new Thread(runnable[loop]).start();
thread[loop].start();
}
System.out.println("RunMultiThread.runMultiThread() method is ended.");
}
}
- 결과
This is ThreadSample's run() method.
This is RunnableSample's run() method.
This is ThreadSample's run() method.
This is RunnableSample's run() method.
This is RunnableSample's run() method.
This is ThreadSample's run() method.
This is RunnableSample's run() method.
This is ThreadSample's run() method.
This is RunnableSample's run() method.
RunMultiThread.runMultiThread() method is ended.
This is ThreadSample's run() method.
위 결과를 보면 순차적으로 실행된 것이 아닌 걸 느낄 수 있다.
새로 생성한 쓰레드는 run() 메소드가 종료되면 끝난다.
Thread 클래스의 생성자
| 생성자 | 설명 | | — | — | | Thread() | 새로운 쓰레드를 생성한다. | | Thread(Runnable target) | 매개 변수로 받은 target 객체의 run() 메소드를 수행하는 쓰레드를 생성한다. | | Thread(Runnable target, String name) | 매개 변수로 받은 target 객체의 run() 메소드를 수행하고, name이라는 이름을 갖는 쓰레드를 생성한다. | | Thread(String name) | name이라는 이름을 갖는 쓰레드를 생성한다. | | Thread(ThreadGroup group, Runnable target) | 매개 변수로 받은 group의 쓰레드 그룹에 속하는 target 객체의 run() 메소드를 수행하는 쓰레드를 생성한다. | | Thread(ThreadGroup group, Runnable target, String name) | 매개 변수로 받은 group의 쓰레드 그룹에 속하는 target 객체의 run() 메소드를 수행하고, name이라는 이름을 갖는 쓰레드를 생성한다. | | Thread(ThreadGroup group, Runnable target, String name, long stackSize) | 매개 변수로 받은 group의 쓰레드 그룹에 속하는 target 객체의 run() 메소드를 수행하고, name이라는 이름을 갖는 쓰레드를 생성한다.단 해당 쓰레드의 스택의 크기는 stackSize만큼만 가능하다. | | Thread(ThreadGroup group, String name) | 매개 변수로 받은 group의 쓰레드 그룹에 속하는 name이라는 이름을 갖는 쓰레드를 생성한다. |
- 모든 쓰레드는 이름이 있음. (이름을 지정하지 않을 경우
"Thread-n"
으로 명명된다. ) - n은 쓰레드가 생성된 순서에 따라 증가한다.
이름을 지정할 경우, 만약 쓰레드 이름이 겹친다고 해도 예외나 에러가 발생하지 않는다.
- ThreadGroup은 쓰레드를 생성할 때 쓰레드를 묶어 놓을 수 있다.
쓰레드를 그룹으로 묶으면 ThreadGroup 클래스에서 제공하는 여러 메소드를 통해서 각종 정보를 얻을 수 있다.
생성자에서 stackSize라는 값은 스택(stack)의 크기를 이야기한다. 쓰레드에서 얼마나 많은 메소드를 호출하는지, 얼마나 많은 쓰레드가 동시에 처리되는지는 JVM이 실행되는 OS의 플랫폼에 따라서 매우 다르다.
참고
여기서 이야기하는 스택은 Collection 설명시 이야기한 Stack이라는 클래스와 전혀 상관 없다. 자바 프로세스가 시작되면 실행 데이터 공간(Runtime data area)이 구성된다. 그 중 하나가 스택이라는 공간이며, 쓰레드가 생성될 때마다 별도의 스택이 할당된다.
public class NameThread extends Thread{
public NameThread(){
}
public void run(){
}
}
NameThread라는 클래스가 Thread 클래스를 확장함. 아무 매개 변수도 없는 Thread() 생성자를 사용하는 것과 동일하다.
만약 쓰레드의 이름을 “ThreadName”으로 지정하고 싶다면, 아래와 같이 NameThread의 생성자를 하면된다.
public NameThread(){
super("ThreadName");
}
Thread(String name)을 호출한 것과 동일한 효과를 본다.
“ThreadName” 이라고 지정해주면, 이 쓰레드 객체를 갯수 제한없이 계속 만들어도 동일한 “ThreadName”이쓰레드를 만들 수 있다.
이러한 단점을 피하기 위해서는 생성자와 매개변수의 개념을 이용해서 극복할 수 있다.
public NameThread(String name){
super(name);
}
Sleep() 메소드에 대해서
static 메소드는 객체를 생성하지 않아도 사용할 수 있는 메소드를 말한다. 따라서 Thread에 있는 static 메소드는 대부분 해당 쓰레드를 위해서 존재하는 것이 아니라, JVM에 있는 쓰레드를 관리하기 위한 용도로 사용된다.
하지만 예외도 존재한다. 그 예외중 하나가 sleep()
메소드다.
리턴 타입 | 메소드 이름 및 매개 변수 | 설명 |
---|---|---|
static void | sleep(long millis) | 매개 변수로 넘어온 시간(1/1000초)만큰 대기한다. |
static void | sleep(long millis, int nanos) | 첫 번째 매개 변수로 넘어온 시간(1/1,000초) + 두 번째 매개 변수로 넘어온 시간(1/1,000,000,000초)만큼 대기한다. |
Thread 클래스의 주요 메소드
리턴 타입 | 메소드 이름 및 매개 변수 | 설명 |
---|---|---|
void | run() | 쓰레드가 시작되면 수행되는 메소드 |
long | getId() | 쓰레드의 고유 id를 리턴한다. JVM에서 자동으로 생성해준다. |
String | getName() | 쓰레드의 이름을 리턴한다. |
void | setName(String name) | 쓰레드의 이름을 지정한다. |
int | getPriorty() | 쓰레드의 우선 순위를 확인한다. |
void | setPriority(int newPriority) | 쓰레드의 우선 순위를 지정한다. |
boolean | isDaemon() | 쓰레드가 데몬인지 확인한다. |
void | setDaemon(boolean on) | 쓰레드를 데몬으로 설정할지 아닌지를 설정한다. |
StackTraceElement[] | getStackTrace() | 쓰레드의 스택 정보를 확인한다. |
Thread.State | getState() | 쓰레드의 상태를 확인한다. |
ThreadGroup | getThreadGroup() | 쓰레드의 그룹을 확인하다. |
public class RunDaemonThreads {
public static void main(String[] args) {
RunDaemonThreads samlple = new RunDaemonThreads();
samlple.checkThreadProperty();
}
public void checkThreadProperty() {
ThreadSample thread1 = new ThreadSample();
ThreadSample thread2 = new ThreadSample();
ThreadSample daemonThread = new ThreadSample();
System.out.println("thread1 id =" + thread1.getId());
System.out.println("thread2 id =" + thread2.getId());
System.out.println("thread1 name =" + thread1.getName());
System.out.println("thread2 name =" + thread2.getName());
System.out.println("thread1 priority = " + thread1.getPriority());
daemonThread.setDaemon(true);
System.out.println("thread1 isDaemon =" + thread1.isDaemon());
System.out.println("daemonThread isDaemon=" + daemonThread.isDaemon());
}
}
- 결과
thread1 id =13
thread2 id =14
thread1 name =Thread-0
thread2 name =Thread-1
thread1 priority = 5
thread1 isDaemon =false
daemonThread isDaemon=true
쓰레드 API 에서 우선순위와 관계 있는 3개의 상수가 있다. 살펴보자면…
상수 | 값 및 설명 |
---|---|
MAX_PRIORITY | 가장 높은 우선 순위이며, 그 값은 10이다. |
MORM_PRIORITY | 일반 쓰레드의 우선 순위이며, 그 값은 5다. |
MIN_PRIORITY | 가장 낮은 우선 순위이며, 그 값은 1이다. |
추가로 daemonThread라는 쓰레드 객체를 데몬 쓰레드를 지정하고 난 후 그 내용을 출력한 것을 볼 수 있다.
이렇게 쓰레드가 수행하기 전에 데몬 여부를 지정해야만 그 쓰레드가 데몬쓰레드로 인식된다.
사용자 쓰레드는 JVM이 해당 쓰레드가 끝날 때까지 기다린다고 했다. 즉 어떤 쓰레드를 데몬 쓰레드로 지정하면 그 쓰레드가 수행되고 있든, 수행되지 않고 있든 상관 없이 JVM이 끝날 수 있다.
단, 쓰레드가 시작하기 전에 데몬 쓰레드로 지정되어야만 한다. 쓰레드가 시작한 다음에는 데몬으로 지정할 수 없다.
쓰레드와 관련이 많은 synchronized
Thread safe에 대해서 예를 들면
다들 고향 내려갈 때 휴게소를 들린다. 휴게소에 보면 길게 줄이 선 화장실을 발견할 수 있다. 이 줄은 먼저 온 사람을 기준으로 차례로 줄을 선다. 하지만 줄을 서지않고 늦게 온사람이 먼저 들어가고 빨리 온 사람이 늦게 들어오는 경우가 생긴다면? 규칙이 없이 너도 나도 먼저 들어가려고 싸울 것이다.
자바에서 마찬가지로 여러 쓰레드가 한 객체에 선언된 메소드에 접근하여 데이터를 처리하려고 할 때 동시에 연산을 수행하여 값이 꼬이는 경우가 발생할 수 있다.(여기서 한 객체라는 것은 하나의 클래스에서 생성된 여러 개의 객체가 아니라, 동일한 하나의 객체를 말한다). 단, 메소드에서 인스턴스 변수를 수정하려고 할 때에만 이러한 문제가 생긴다.
매개 변수나 메소드에서만 사용하는 지역변수만 다루는 메소드는 전혀 synchronized로 선언할 필요가 없다.
synchronized
사용하는 방법
- 메소드를 synchronized로 선언하는 방법(synchronized methods)
- 다른 하나는 메소드 내의 특정한 문장만 synchronized로 감싸는 방법(synchronized statements)
public void plus(int value){
amount+=value;
}
이 메소드를 synchronized로 선언하면 ?
public synchronized void plus(int value){
amount+=value;
}
synchronized라는 단어가 메소드 선언부에 있으면, 동일한 객체의 이 메소드에 몇개의 쓰레드가 접근하든 하나의 쓰레드만 수행하게된다.
예제를 통해서 알아보자
- 연산을 수행하는 클래스
CommonCalculate
- plus() 메소드에서는 매개 변수로 받은 값을 더한다.
- minus() 메소드에서는 매개 변수로 받은 값을 뺀다.
- getAmount() 메소드에서는 현재의 amount 값을 출력한다.
public class CommonCalculate{
private int amount;
public CommonCalculate(){
amount = 0;
}
public void plus(int value){
amount += value;
}
public void minus(int value){
amount += value;
}
public int getAmount(){
return amount;
}
}
ModifyAmountThread
클래스는 객체를 매개 변수로 받아서 처리하는 다음의 쓰레드가 있다.- CommonCalculate 클래스의 객체를 받아서 addFlag가 true면 1을 더하고, false면 1을 빼는 연산을 수행한다. 덧셈이나 뺄샘 연산을 만번 수행하고 나서 해당 쓰레드는 종료된다.
public class ModifyAmountThread extends Thread{
private CommonCalculate calc;
private boolean addFlag;
public ModifyAmountThread(CommonCalculate calc, boolean addFlag) {
this.calc = calc;
this.addFlag= addFlag;
}
public void run() {
for (int loop = 0; loop < 10000; loop++) {
if (addFlag) {
calc.plus(1);
}else{
calc.minus(1);
}
}
}
}
- 쓰레드를 실행하는 RunSync
public class RunSync {
public static void main(String[] args) {
RunSync runSync = new RunSync();
runSync.runCommonCalculate();
}
public void runCommonCalculate() {
CommonCalculate calc = new CommonCalculate(); // 1
ModifyAmountThread thread1 = new ModifyAmountThread(calc, true); //2
ModifyAmountThread thread2 = new ModifyAmountThread(calc, true); //2
thread1.start(); // 3
thread2.start(); // 3
try {
thread1.join(); // 4
thread2.join(); // 4
System.out.println("Final value is " + calc.getAmount()); // 5
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
- 결과
Final value is 19511
예상한 결과와 다르다.
그렇다면 여러 번 반복하도록 변경해보자. 5번만 반복하는 방법
public static void main(String[] args) {
RunSync runSync = new RunSync();
for (int i = 0; i < 5; i++) {
runSync.runCommonCalculate();
}
}
- 결과
Final value is 17747
Final value is 13271
Final value is 14192
Final value is 11174
Final value is 20000
만약 ModifyAmountThread라는 쓰레드에서 반복 하는 횟수가 적으면 적을수록 결과는 우리가 예상한 값에 가깝거나, 예상한 대로 출력될 것이다. 하지만 반복 횟수가 많아질수록, 그 결과는 정상적인 결과와 멀어진다.
왜 이런 결과가 나왔을까?
이유는 plus()
라는 메소드 때문이다.
이 메소드는 다른 쓰레드에서 작업하고 있다고 하더라도, 새로운 쓰레드에서 온 작업도 같이 처리한다. 따라서 데이터가 꼬일 수 있다.
- plus() 내부 연산은 아래와 같이 생겼다.
amount = amount + value;
예를 들어 우측 항에 있는 amount가 1이고, value가 1일 경우, 정상적인 경우라면 좌측 항의 결과에는 2가 된다. 그런데 좌측 항에 2라는 값을 치환하기 전에 다른 쓰레드가 또 들어와서 이 연산을 수행하려고 한다.
아직 amount는 2가 안 된 상황에서 amount는 1이다. 따라서, 먼저 계산된 결과에서 2를 치환한다고 하더라도, 그 다음에 들어온 쓰레드도 1과 1을 더하기 때문에 다시 amount에 2를 치환한다.
표로 본다면
이렇게 동시에 연산이 수행되기 때문에 우리가 원한 20,000이라는 값이 출력되지 않은 것이다.
은행에서도 직원이 한 창구에서 한 고객의 요청만 처리한다. 만약 한 번에 여러 고객의 요청을 처리하면 해당 창구는 고객의 요청이 뒤죽박죽 되어서 한 건도 제대로 처리하기 어렵게 될 것이다.
이러한 문제를 해결하기 위한 것이 synchronized
이다.
바꾸게 된다면
public synchronized void plus(int value){
amount+= value;
}
public synchronized void minus(int value){
amount+=value;
}
- 결과
Final value is 20000
Final value is 20000
Final value is 20000
Final value is 20000
Final value is 20000
언제 수행하든지, 이 예제가 수행한 결과는 우리가 원한 동일한 20,000이라는 결과를 출력한다.
이제 plus()
와 minus()
메소드는 쓰레드에 안전하다고 할 수 있다.
synchronized 블록 사용
앞서 배운 메소드에 synchronized를 추가하는 것은 때로는 문제가 발생할 수 도 있다.
예를 들어
어떤 클래스에 30줄 짜리 메소드가 있다고 가정해보자.
그 클래스에도 amount라는 인스턴스 변수가 있고, 30줄짜리 메소드에서 amount라는 변수를 한 줄에서만 다룬다. 만약 해당 메소드 전체를 synchronized로 선언한다면, 나머지 29줄 처리를 할 때 필요 없는 대기 시간이 발생하게 된다. 이러한 경우에는 메소드 전체를 감싸면 안된다.
amount라는 변수를 처리하는 부분만 synchronized처리를 해 주면 된다.
public void plus(int value){
synchronized(this){
amount+=value;
}
}
public void minus(int value){
synchronized(this){
amount-=value;
}
}
이렇게하면 synchronized(this) 이후에 있는 중괄호 내에 있는 연산만 동시에 여러 쓰레드에서 처리하지 않겠다는 의미다.
소괄호 안에 this가 있는 부분에는 잠금 처리를 하기 위한 객체를 선언한다.
보통 this라고하는 것보다 별도의 객체를 선언하여 사용한다.
Ojbect lock = new Object();
public void plus(int value){
synchronized(lock) {
amount+=value;
}
}
public void minus(int value){
synchronized(lock) {
amount-=value;
}
}
lock이라는 하나의 잠금용 객체만을 사용하면 amount라는 변수를 처리할 때, 효율적으로 처리할 수 있다.
private int amount;
private int interest;
public void addInterest(int value) {
interest+=value;
}
public void plus(int value){
amount+=value;
}
- 효율적으로 처리하면
private int amount;
private int interest;
private Object interestLock = new Object();
private Object amountLock = new Object();
public void addInterest(int value) {
synchronized(interestLock) {
interest+=value;
}
}
public void plus(int value){
synchronized(amountLock) {
amount+=value;
}
}
synchronized
는 여러 쓰레드에서 하나의 객체 있는 인스턴스 변수를 동시에 처리할 때 발생할 수 있는 문제를 해결하기 위해서 필요한 것이라는 점이다.
즉, 인스턴스 변수가 선언되어 있다고 하더라도, 변수가 선언되어 있는 객체를 다른 쓰레드에서 공유할 일이 전혀 없다면 synchronized
를 사용할 이유가 전혀 없다.
쓰레드를 통제하는 메소드들
리턴 타입 | 메소드 이름 및 매개 변수 | 설명 |
---|---|---|
Thread.State | getState() | 쓰레드의 상태 확인한다. |
void | join() | 수행중인 쓰레드가 중지할 때까지 대기한다. |
void | join(long millis) | 매개 변수에 지정된 시간만큼(1/1,000초) 대기한다. |
void | join(long millis, int nanos) | 첫 번째 매개 변수에 지정된 시간(1/1,000초) 대기한다. |
void | interrupt() | 수행중인 쓰레드에 중지 요청을 한다. |
➡️getState() 메소드에서 리턴하는 Thread.State에 대해서
- Thread클래스에는 State라는 enum 클래스가 있다. 그 클래스에 선언되어 있는 상수들의 목록
상태 | 의미 |
---|---|
NEW | 쓰레드 객체는 생성되었지만, 아직 시작되지는 않은 상태 |
RUNNABLE | 쓰레드가 실행중인 상태 |
BLOCKED | 쓰레드가 실행 중지 상태이며, 모니터 락(monitor lock)이 풀리기를 기다리는 상태 |
WAITING | 쓰레드가 대기중인 상태 |
TIMED_WAITING | 특정 시간만큼 쓰레드가 대기중인 상태 |
TERMINATED | 쓰레드가 종료된 상태 |
이 클래스는 public static으로 선언되어 있다. 다시 말하면, Thread.State.NEW와 같이 사용할 수 있다는 의미이다.
➡️쓰레드의 라이프 사이클
“NEW → 상태 → TERMINATED”
- 여기서
상태
에 해당하는 것은 NEW와 TERMINATED를 제외한 모든 다른 상태를 의미한다.