본문 바로가기
JAVA/이재환의 자바 프로그래밍 입문

[Java] Ch.25 스레드

by ♡˖GYURI˖♡ 2023. 10. 27.
728x90

스레드의 이해

운영체제에서 실행 중인 프로그램 = 프로세스

예전 DOS 운영체제 환경에서는 한 번에 한 프로그램만이 실행되었음

 

현대 운영체제인 윈도우, 맥OS, 리눅스 등에서는 동시에 여러 프로그램이 실행됨

두 가지 이상의 작업을 동시에 처리하는 것 = 멀티태스킹

 

프로세스는 자신만의 자원을 가짐

여러 프로세스가 동시에 실행도더라도 자신만의 메모리를 사용하기 때문에 서로 독립적임

 

실행 중인 애플리케이션, 즉 프로세스에서도 동시에 수행할 수 있는 다수의 코드 블록이 있을 수 있음

ex. 웹 브라우저는 다운로드가 진행 중일 때 계속해서 검색을 할 수 있음

이 작업들은 서로 독립적이어서 동시에 실행할 수 있음

 

자바 애플리케이션은 JVM 위에서 동작하며, 하나의 JVM은 하나의 애플리케이션을 실행할 수 있음

이 애플리케이션 안에서 앞에서 설명한 웹 브라우저처럼 여러 작업을 동시에 수행할 수 있는데, 이를 스레드라고 함

스레드들은 각자의 자원을 가지고 독립적으로 실행됨

(자바 스레드를 관리하는 일은 모두 JVM이 담당함, 프로세스 관리는 운영체제가 담당함)

 

  • 스레드는 하나의 실행 흐름으로 프로세스 내부에 존재함
  • 프로세스는 하나 이상의 실행 흐름을 포함하기 때문에 프로세스는 적어도 하나의 스레드를 가짐
public class Ex01_CurrentThread
{
    public static void main(String[] args)
    {
        String name = Thread.currentThread().getName();
        System.out.println("현재 스레드 이름 : " + name);
    }
}

 

 

스레드 생성과 실행

자바는 다음 두 가지 방법으로 스레드를 작성할 수 있음

  • Thread 클래스를 상속받아 run() 메서드 오버라이딩
  • Runnable 인터페이스 구현

Thread 클래스와 Runnable 인터페이스는 java.lang 패키지에 포함되어 있기 때문에 따로 임포트할 필요 없음

 

Thread 클래스를 상속받아 만들기

class MyThread2 extends Thread
{
    // 스레드를 실행하면 해당 스레드에서 run() 메서드 호출
    public void run() 
    {
        int sum = 0;
        for (int i=0; i<10; i++)
            sum = sum + i;
        String name = Thread.currentThread().getName();
        System.out.println(name + ": " + sum);    
    }
}

public class Ex02_ThreadClass
{
    public static void main(String[] args)
    {
        MyThread2 t = new MyThread2();
        // 스레드의 run() 메서드 이름을 바로 호출하지 않고 start() 메서드를 호출해야 run() 메서드가 실행됨
        t.start();	
        System.out.println("main: " + Thread.currentThread().getName());
    }
}

스레드 실행은 메서드 호출과는 처리 방식이 다름

메서드 호출은 결과를 기다렸다 다음 라인이 실행되지만 스레드 실행은 시작하라는 명령만 내리고 바로 다음 라인으로 실행이 옮겨감

실행된 스레드는 메인 스레드와는 별도로 자기 자신만의 실행 순서로 main 스레드와 동시에 실행됨

 

다만 메인 블록의 코드가 다 실행되었다고 해도 스레드가 실행되고 있다면 스레드 실행이 끝날 때까지 메인 블록 종료가 지연됨 (메인 블록이 끝나면 프로그램이 종료되기 때문)

 

Runnable 인터페이스 구현하기

자바는 다중 상속이 안 되기 때문에 Thread 클래스를 상속받아 스레드를 만들면 구현이 힘든 상황이 생김

그럴 때는 Runnable 인터페이스를 구현하여 스레드를 만들면 됨

class MyThread3 implements Runnable 
{
    public void run() 
    {
        int sum = 0;
        for (int i=0; i<10; i++)
            sum = sum + i;
        String name = Thread.currentThread().getName();
        System.out.println(name + ": " + sum);    
    }
}

public class Ex03_RunnableInterface1
{
    public static void main(String[] args)
    {
    	// 생성자 인수로 우리가 만든 클래스의 객체를 넘겨줌
        Thread t = new Thread(new MyThread3());
        t.start();

        System.out.println("main: " + Thread.currentThread().getName());
    }
}

스레드명을 지정하지 않았기 때문에 일련번호가 붙여진 이름이 반환됨

 

람다식으로 Runnable 구현하기

스레드를 구현하는 클래스에 run() 메서드만 있다면 많은 부분을 생략하고 익명 클래스나 람다식으로 구현할 수 있음

 

람다식 예제

public class Ex04_RunnableInterface2
{
    public static void main(String[] args)
    {
        Runnable task = () -> {
            try 
            {
            	// 스레드의 실행이 3초 동안 일시 정지했다가 다시 진행됨
                Thread.sleep(3000);
            }
            catch (Exception e) { }

            int sum = 0;
            for (int i=0; i<10; i++)
                sum = sum + i;
            String name = Thread.currentThread().getName();
            System.out.println(name + ": " + sum);
        };

		// 생성자 인수로 우리가 만든 람다식을 대입한 변수를 넘김
        Thread t = new Thread(task);
        t.start();

        System.out.println("main: " + Thread.currentThread().getName());
    }
}

 

여러 개의 스레드 동시 실행

public class Ex05_MultiThread
{
    public static void main(String[] args)
    {
        Runnable task1 = () -> {
            try 
            {
                for (int i=0; i<20; i=i+2)  // 20 미만 짝수 출력
                {
                    System.out.print(i + " ");
                    Thread.sleep(1000);	// 1000밀리세컨드(1초) 쉼
                }
            }
            catch(InterruptedException e) { }
        };

        Runnable task2 = () -> {
            try 
            {
                for (int i=9; i>0; i--)   // 10 미만 수 출력
                {
                    System.out.print("(" + i + ") ");
                    Thread.sleep(500);	// 500밀리세컨드 쉼
                }
            }
            catch(InterruptedException e) { }
        };

        Thread t1 = new Thread(task1);
        Thread t2 = new Thread(task2);

        t1.start();
        t2.start();
    }
}

 

 

스레드 동기화

동일한 변수의 값을 증감시키는 두 스레드가 있다고 가정함

변수의 값은 메모리에 있음

이 변수의 값을 증감하는 연산을 하려면 CPU로 값을 옮겨와서 값을 증감시키는 연산을 수행하고 나서 다시 메모리에 저장해야 함

이런 과정이 있기 때문에 여러 스레드가 같은 변수의 값을 증감시키는 연산을 수행하면 문제가 발생하게 됨

 

스레드에서의 문제점

public class Ex06_ProblemOfThread
{
    public static int money = 0;

    // InterruptedException 예외가 발생하면 예외를 넘기고 종료
    public static void main(String[] args) throws InterruptedException
    {
        Runnable task1 = () -> {
            for(int i = 0; i<10000; i++)
                money++;
        };

        Runnable task2 = () -> {
            for(int i = 0; i<10000; i++)
                money--;
        };

        Thread t1 = new Thread(task1);
        Thread t2 = new Thread(task2);
        
        t1.start();
        t2.start();
    
        t1.join();    // t1이 참조하는 스레드의 종료를 기다림
        t2.join();    // t2이 참조하는 스레드의 종료를 기다림
     
        // 스레드가 종료되면 출력을 진행함. 위 join의 영향
        System.out.println(money);
    }

}

 

스레드 동기화로 문제점 해결

자바에서는 스레드에서 동기화를 사용하여 이런 문제를 해결함

 

동기화 시키는 방법

  • 메서드에 synchronized 키워드 지정
  • 코드의 일부에 동기화 블록을 지정
// 메서드 전체 동기화
public synchronized void 메서드() {
	// 동기화 대상 코드
}
// 코드 일부 동기화
public void 메서드() {
	synchronized (공유객체)
    {
    	// 동기화 대상 코드
    }
}

 

public class Ex07_SyncMethod
{
    public static int money = 0;

    public synchronized static void deposit()
    {
        money++;
    }
    
    public synchronized static void withdraw()
    {
        money--;
    }

    public static void main(String[] args) throws InterruptedException
    {
        Runnable task1 = () -> {
            for (int i = 0; i<10000; i++)
                deposit();
        };

        Runnable task2 = () -> {
            for (int i = 0; i<10000; i++)
                withdraw();
        };

        Thread t1 = new Thread(task1);
        Thread t2 = new Thread(task2);
        
        t1.start();
        t2.start();
    
        t1.join();    // t1이 참조하는 스레드의 종료를 기다림
        t2.join();    // t2이 참조하는 스레드의 종료를 기다림
     
        // 스레드가 종료되면 출력을 진행함. 위 join의 영향
        System.out.println(money);
    }
}

이렇게 동기화가 메서드나 블록에 적용되면 동기화 영역이 실행되는 동안 다른 스레드의 접근을 제한하게 됨

동기화 영역의 실행이 끝나면 이제 다른 스레드에서도 접근이 가능하게 됨

그러므로 이 부분에 많은 스레드가 접근하게 된다면 병목 현상이 발생할 수 있음

 

 

스레드와 풀

스레드 개수가 많아지면 스레드 객체 생성과 소멸, 스케줄링 등에 CPU와 메모리에 많은 부하가 발생함

스레드의 생성과 소멸은 리소스 소모가 많은 작업임

웹 서버처럼 소규모의 많은 요청이 들어올 때마다 스레드를 생성 및 종료하면 오버헤드가 발생함

거기에 생성되는 스레드 개수에 제한이 없다면 OutOfMemoryError가 발생할 수 있음

 

따라서 생성과 종료를 반복해 사용하는 스레드라면 재활용하고 동시에 실행하는 스레드 개수도 제한하여 CPU와 메모리에 가해지는 부하를 줄일 필요가 있음

이런 목적으로 자바에 스레드 관련 java.util.concurrent 패키지가 추가됨

 

스레드 풀(thread pool)은 제한된 개수의 스레드를 JVM에 관리하도록 맡기는 방식

실행할 작업을 스레드 풀로 전달하면 JVM이 스레드 풀의 유휴 스레드(idle thread) 중 하나를 선택해서 스레드로 실행시킴

 

스레드풀의 대표 유형

  • newSingleThreadExecutor : 풀 안에 하나의 스렏만 생성하고 유지함, 스레드의 숫자가 하나이고 하나의 태스크가 완료된 이후에 다음 태스크가 실행됨, 즉 여러 스레드가 동시에 실행되지 않으므로 동기화가 필요 없음
  • newFixedThreadPool : 풀 안에 인수로 전달된 수의 스레드를 생성하고 유지함, 초기 스레드 개수는 0개, 코어 스레드 수와 최대 스레드 수는 매개변수 nTreads값으로 지정함, 만약 생성된 스레드가 놀고 있어도 스레드를 제거하지 않고 내버려 둠
  • newCachedTreadPool : 풀 안의 스레드의 수를 작업의 수에 맞게 유동적으로 관리함, 초기 스레드와 코어 스레드 개수는 0개, 최대 스레드 수는 integer 데이터형이 가질 수 있는 최댓값임, 만약 스레드가 60초 동안 아무 일도 하지 않으면 스레드를 종료시키고 스레드 풀에서 제거함
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Ex08_ThreadPool1
{
    public static int money = 0;

    public static void main(String[] args)
    {
        Runnable task1 = () -> {     // 스레드에게 시킬 작업
            for (int i = 0; i<10000; i++)
                money++;
            String name = Thread.currentThread().getName();
            System.out.println(name + ": " + money);
        };
        
        Runnable task2 = () -> {     // 스레드에게 시킬 작업
            for (int i = 0; i<10000; i++)
                money--;
            String name = Thread.currentThread().getName();
            System.out.println(name + ": " + money);
        };
        
        ExecutorService pool = Executors.newSingleThreadExecutor();
        pool.submit(task1);    // 스레드 풀에 작업을 전달
        pool.submit(task2);    // 스레드 풀에 작업을 전달

        System.out.println("End " + Thread.currentThread().getName());

        pool.shutdown();    // 스레드 풀과 그 안에 있는 스레드의 소멸
    }
}

동기화를 사용하지 않고도 공유된 변수의 사용에 안전한 결과를 얻고 있음

하지만 이 방식은 스레드가 동시에 여러 개 실행되는 것이 아니기에 스레드라기보다는 메서드의 사용 방식과 비슷함

 

최대 스레드 수가 2개인 스레드 풀

스레드 풀의 개수를 2개로 지정하고 3개를 전달하여 사용하는 예제

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Ex09_ThreadPool2
{
    public static void main(String[] args)
    {
        Runnable task1 = () -> {
            String name = Thread.currentThread().getName();
            try 
            {
                Thread.sleep(5000);
            }
            catch (Exception e) { }
            System.out.println(name + ": 5초 후 실행");
        };
        
        Runnable task2 = () -> {
            String name = Thread.currentThread().getName();
            System.out.println(name + ": 바로 실행");
        };
               
        Runnable task3 = () -> {
            String name = Thread.currentThread().getName();
            try {
                Thread.sleep(2000);
            }
            catch (Exception e) { }
            System.out.println(name + ": 2초 후 실행");
        };
               
        // 스레드 풀에서 수행될 수 있는 스레드의 총량을 제한
        ExecutorService pool = Executors.newFixedThreadPool(2);
        pool.submit(task1);
        pool.submit(task2);
        pool.submit(task3);

        pool.shutdown();
    }
}

 

 

Callable & Future

스레드는 실행만 시켜줄 수 있고, 스레드로부터 결과를 반환받을 수 없음

Executor 프레임워크를 사용하면 작업 대상의 Callable 객체를 만들고 ExecutorService에 등록한 다음 태스크 처리가 끝난 다음 작업 결과를 Future 객체를 통해서 반환받을 수 있음

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Ex10_CallableFuture
{
    public static void main(String[] args) 
            throws InterruptedException, ExecutionException
    {
        Callable<Integer> task1 = () -> {
        	Thread.sleep(2000);
            return 2 + 3;
        };
        
        Callable<Integer> task2 = () -> {
        	Thread.sleep(10);
            return 2 * 3;
        };

        ExecutorService pool = Executors.newFixedThreadPool(2);
        Future<Integer> future1 = pool.submit(task1);
        Future<Integer> future2 = pool.submit(task2);

        System.out.println("이 내용이 먼저 출력됩니다.");
        
        Integer r1 = future1.get();
        System.out.println("result: " + r1);

        Integer r2 = future2.get();
        System.out.println("result: " + r2);

        pool.shutdown();
    }
}

 

 

ReentrantLock 클래스 : 명시적 동기화

기존의 synchronized는 메서드 전체나 구간을 묶어서 동기화시켰음

ReentrantLock 클래스를 사용하면 시작점과 끝점을 명백히 명시할 수 있음

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

class BankAccount
{ 
    ReentrantLock myLock = new ReentrantLock();
    int money = 0;

    public void deposit() 
    {
        myLock.lock();	// 동기화 시작점 설정
        money++;
        myLock.unlock();	// 동기화 끝점 설정
    }

    public void withdraw() 
    {
        myLock.lock();
        money--;
        myLock.unlock();
    }

    public int balance() 
    {
        return money; 
    }
}

public class Ex11_ReentrantLock
{
    // 여러 스레드에서 사용할 객체 -> static
    public static BankAccount account = new BankAccount();	

    public static void main(String[] args) throws InterruptedException
    {
        Runnable task1 = () -> {
            for (int i = 0; i < 10000; i++)
            	account.deposit();
        };

        Runnable task2 = () -> {
            for (int i = 0; i < 10000; i++)
            	account.withdraw();
        };

        ExecutorService pool = Executors.newFixedThreadPool(2);
        pool.submit(task1);
        pool.submit(task2);
     
        pool.shutdown();
        // 스레드 풀이 완전하게 종료되기를 안전하게 조금 더 기다림
        pool.awaitTermination(100, TimeUnit.SECONDS);	
        System.out.println(account.balance());
    }
}

 

 

컬렉션 객체 동기화

여러 스레드가 동시에 컬렉션 객체에 접근하여 요소를 변경하면 의도하지 않게 요소가 변경될 수 있음

 

동기화되지 않은 컬렉션 객체의 사용

다음 예제는 리스트 변수에 대해서 동기화를 적용하지 않았음

다루는 데이터가 작기 때문에 결과가 정상적으로 보일 때도 있지만, 여러번 실행하면 이상한 결과가 나오기도 함

import java.util.ArrayList;
import java.util.List;
import java.util.ListIterator;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Ex12_SyncArrayList1
{
    public static List<Integer> list = new ArrayList<>();

    public static void main(String[] args) throws InterruptedException
    {
        for (int i = 0; i < 10; i++)
            list.add(i);
        System.out.println(list);

        Runnable task = () -> {
            ListIterator<Integer> itr = list.listIterator();
            
            while (itr.hasNext())
                itr.set(itr.next() + 1); 
        };

        ExecutorService pool = Executors.newFixedThreadPool(5);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);
     
        pool.shutdown();
        pool.awaitTermination(100, TimeUnit.SECONDS);

        System.out.println(list);
    }
}

 

synchronized를 이용한 동기화

컬렉션 프레임워크도 synchronized를 사용하여 동기화를 하면 정상적으로 처리 가능

import java.util.ArrayList;
import java.util.List;
import java.util.ListIterator;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Ex13_SyncArrayList2
{
    public static List<Integer> list = new ArrayList<>();

    public static void main(String[] args) throws InterruptedException
    {
        for (int i = 0; i < 10; i++)
            list.add(i);
        System.out.println(list);

        Runnable task = () -> {
            // list 객체를 사용할 때 객체에 동기화 Lock을 설정
            synchronized(list) 
            {
                ListIterator<Integer> itr = list.listIterator();

                while (itr.hasNext())
                    itr.set(itr.next() + 1); 
            }
        };

        ExecutorService pool = Executors.newFixedThreadPool(5);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);
     
        pool.shutdown();
        pool.awaitTermination(100, TimeUnit.SECONDS);

        System.out.println(list);
    }
}

 

Collections 클래스의 메서드를 이용한 동기화

자바는 비동기화된 메서드를 동기화된 메서드로 래핑하는 Collections의 synchronizedXXX() 메서드를 제공함

반환형 메서드(매개변수) 설명
List synchronizedList(List list) List를 동기화된 List로 반환
Set synchronizedSet(Set s) Set을 동기화된 Set으로 반환
Map<K, V> synchronizedMap(Map<K, V> m) Map을 동기화된 Map으로 반환
// thread-safe
List<T> list = Collections.synchronizedList(new ArrayList<T>());
Set<E> set = Collections.synchronizedSet(new HashSet<E>());
Map<K, V> map = Collections.synchronizedMap(new HashMap<K, V>());

 

하지만 컬렉션 객체의 동기화를 이렇게 했다고 하더라도 이 컬렉션 객체를 기반으로 생성하는 반복자는 별도로 동기화를 다시 해주어야 함

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.ListIterator;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Ex14_SyncArrayList3
{
    //public static List<Integer> list = new ArrayList<>();
    public static List<Integer> list = 
            Collections.synchronizedList(new ArrayList<>());

    public static void main(String[] args) throws InterruptedException
    {
        for (int i = 0; i < 10; i++)
            list.add(i);
        System.out.println(list);

        Runnable task = () -> {
            // list 객체를 사용할 때 객체에 동기화 Lock을 설정
            synchronized(list) 
            {
                ListIterator<Integer> itr = list.listIterator();

                while (itr.hasNext())
                    itr.set(itr.next() + 1); 
            }
        };

        ExecutorService pool = Executors.newFixedThreadPool(5);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);
     
        pool.shutdown();
        pool.awaitTermination(100, TimeUnit.SECONDS);

        System.out.println(list);
    }
}

반복자는 동기화 처리를 해야 함

 

ConcurrentHashMap 이용

스레드가 컬렉션 객체의 요소를 처리할 때 전체 잠금이 발생하여 컬렉션 객체에 접근하는 다른 스레드는 대기 상태가 됨

객체의 요소를 다루는 것은 안전해졌지만, 처리 속도는 느려진 것

따라서 자바는 멀티스레드가 컬렉션의 요소를 병렬적으로 처리할 수 있도록 java.util.concurrent 패키지에서 ConcurrentHashMap, ConcurrentLinkedQueue를 제공함

 

이 클래스는 부분적으로 잠금을 사용하기 때문에 객체의 요소를 처리할 때 스레드에 안전하면서 빠르게 처리 가능해짐

Map<K, V> map = new ConcurrentHashMap<K, V>();
Queue<E> queue = new ConcurrentQueue<E>();

 

import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Ex15_ConcurrentHashMap
{
    public static Map<String, Integer> syncMap = null;	// Collections.synchronizedMap
    public static Map<String, Integer> concMap = null;	// CorrentHashMap

    public static void performanceTest(final Map<String, Integer> target)
            throws InterruptedException 
    {
        System.out.println("Start : " + Thread.currentThread().getName());
        Instant start = Instant.now();

        Runnable task = () -> {
            for (int i = 0; i<100000; i++)
                target.put(String.valueOf(i), i);
        };
        
        ExecutorService pool = Executors.newFixedThreadPool(5);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);

        pool.shutdown();
        pool.awaitTermination(100, TimeUnit.SECONDS);

        Instant end = Instant.now();
        System.out.println("End : " + Duration.between(start, end).toMillis());
    }
    
    public static void main(String[] args) throws InterruptedException
    {
        syncMap = Collections.synchronizedMap(new HashMap<>());
        performanceTest(syncMap);

        concMap = new ConcurrentHashMap<>();
        performanceTest(concMap);
    }
}

Collections.synchronizedMap을 사용할 때보다 ConcurrentHashMap을 사용할 때 속도가 더 빠름