쉽게 쉽게

쓰레드 본문

개발공부/Java

쓰레드

곱마2 2023. 3. 28. 13:22
반응형

이 글은 '자바의 정석'의 내용을 기반으로 공부한 내용을 덧붙인 글입니다.


1. 쓰레드란

쓰레드를 설명하기 이전에 먼저 프로세스를 알아야 한다.

프로세스란 실행 중인 프로그램을 의미한다.
프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)을 할당받아 프로세스가 된다.
이런 프로세스는 프로그램을 수행하는 데 필요한 데이터, 메모리 등의 자원 그리고 쓰레드로 구성되어 있다.

OS에서 실행 중인 하나의 애플리케이션 즉 ctrl + alt + del창 작업 관리자에서 프로세스 탭에 올라와 있는 어플리케이션 하나를 하나의 프로세스라고 부른다. ex) Chrome

만약 우리가 크롬창을 더블클릭 누른다면(실행) 운영체제로부터 필요한 메모리를 할당받아 애플리케이션의 코드를 실행하는 것이 프로세스이다.

크롬을 2개 띄웠다면 두 개의 프로세스가 생성된 것이다.

 

그렇다면 쓰레드란?

프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 쓰레드이다.

프로그램 코드를 한 줄씩 실행하는 것이 쓰레드의 역할이다(=실행제어)

한 프로그램에 여러 개의 쓰레드가 존재할 수 있다.

쓰레드가 작업을 수행하는데 개별적인 메모리 공간(호출스택)을 필요로 하기 때문에 프로세스의 메모리 한계에 따라 생성할 수 있는 쓰레드의 수가 결정된다.

쓰레드가 1개라면 단일스레드, 2개 이상이라면 다중쓰레드

2. 쓰레드의 구현

쓰레드를 구현하는 방법은 2가지가 있다.

(1) Thread클래스 상속

class thread_1 extends Thread{
public void run() {내용}

thread_1 t1 = new thread_1();
t1.start();

(2) Runnable인터페이스 구현

class thread_2 implements Runnable{
public void run() {내용}

Runnable r = new thread_2(); //Runnable을 구현한 클래스의 인스턴스 생성
thread t2 = new thread(r); //생성자(Thread(Runnable target), (thread t2 = new thread(new Thread_2()))로 요약 가능
t2.start();

보통 Thread클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에, Runnable인터페이스를 구현하는 것이 일반적이다.

Thread클래스를 상속받은 경우와 Runnable인터페이스를 구현한 경우의 인스턴스 생성방법이 다르다.

쓰레드를 구현한다는 것은 run()의 몸통을 채운다는 것이다.

Thread클래스를 상속받으면 자손 클래스에서 조상인 Thread클래스의 메서들르 직접 호출할 수 있지만,
Runnable인터페이스를 구현하면 Thread클래스의 static메서드인 currentThread()를 호출하여 쓰레드에 대한 참조를 얻어와야 호출이 가능하다.

  class thread_1 extends Thread{
            public void run() {
                System.out.println(getName()); // 조상Thread의 이름 호출
            }
        }

        class thread_2 implements Runnable{
            public void run() {
                System.out.println(Thread.currentThread().getName()); // 조상Thread의 이름 호출
            }
        }

쓰레드를 생성했다고 해서 자동으로 실행되는 것은 아니다.
start()를 호출해야만 쓰레드가 실행된다.

단 start()가 호출되었다고 해서 바로 실행되는 것이 아니라, 실행대기 상태가 되어 자신의 차례를 기다리게 된다.

쓰레드의 실행순서는 OS의 스케쥴러에 의해 결정된다.

주의할 점으로 한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다.

즉 하나의 쓰레드에 대해 start()가 한 번만 호출될 수 있다는 것이다.

thread_1 t1 = new thread_1();
t1.start(); 
t1.start(); // 예외 발생

다시 실행하고 싶다면
t1 = new thread_1(); // 다시 생성 후에 실행
t1.start();

★ 쓰레드 생성 후 호출스택의 변화

run()을 호출하는 것은 쓰레드를 실행하는 것이 아닌 단순히 클래스에 선언된 메서드를 호출하는 것뿐이다.

반면에 start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성한 다음에 run()을 호출해서, 생성된 호출스택에 run()이 첫 번째로 올라가게 한다.

모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택이 필요하다.

새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 작업에 사용된 호출스택은 소멸한다.

아래는 start()를 호출한 후 호출스택의 변화이다.


(1) main메서드에서 쓰레드의 start()를 호출 한다.

(2) start()는 새로운 쓰레드를 생성하고, 쓰레드가 작업하는데 사용될 호출 스택을 생성한다.

(3) 새로 생성된 호출스택에 run()이 호출되어, 쓰레드가 독립된 공간에서 작업을 수행한다.

(4) 이제는 호출스택이 2개이므로 스케줄러가 정한 순서에 의해서 번갈아 가면서 실행된다.

3. 쓰레드의 메서드

아래는 쓰레드의 메서드를 나타낸다.

setPriority()
쓰레드의 우선순위를 지정한다(10이 최고, 1이 최하, main은 5)

getPriority()
쓰레드의 우선순위를 반환

getState()
쓰레드의 상태 확인

sleep(millis)
지정된 시간동안 쓰레드를 일시정지, 지정한 시간이 지나고 나면, 자동적으로 다시 실행대기상태가 된다.

join()
join(millis)
지정된 시간동안 쓰레드가 실행되도록 한다. 지정한 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 돌아와 실행을 계속한다.

interrupt()
sleep()이나 join()에 의해 일시정지 상태인 쓰레드를 깨워서 실행대기상태로 만든다. 해당 쓰레드에서는 
InterruptedExcetion이 발생함으로써 일시정지를 벗어남(try-catch문으로 예외처리 필수)

boolean interrupted()
쓰레드에 interrupt가 호출되었는지 알려준다.

stop()
쓰레드를 즉시 종료시킨다.

suspend()
쓰레드를 일시정지 시킨다.

resume()
suspend()에 의해 일시정지 상태에 있는 쓰레드를 실행대기상태로 만든다.

yield()
자신에게 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행대기상태가 된다.

- stop(), suspend(), resume()은 교착상태를 만들기 쉽기 때문에 deprecated가 되었다.

아래는 쓰레드의 상태를 나타낸다.

NEW
쓰레드가 생성되고 아직 start()가 호출되지 않은 상태

RUNNABLE(실행대기)
실행 중 또는 실행 가능한 상태

BLOCKED(일시정지)
동기화 블록에 의해서 일시정지된 상태

WAITING
TIMED_WAITING(일시정지)
쓰레드의 작업이 종료되지는 않았지만 일시정지상태
일시정지시간이 지정된 경우

TERMINATED(종료)
쓰레드의 작업이 종료된 형태

전체적으로 요약하자면 이렇다.

(1) sleep() 메서드

static void sleep(long millis)
static void sleep(long millis, int nanos)

sleep()지정된 시간 동안 쓰레드를 멈추게 한다.

TIMED_WAITING(일시정지)상태로 전이된다.

sleep()에 의해 일시정지 상태가 된 쓰레드는 지정된 시간이 다 되거나 interrupt()가 호출되면 잠에서 깨어나 실행대기 상태가 된다. (RUNNABLE 상태)

 try {
     Thread.sleep(2000); // 2초동안 멈추게 한다.
       } catch (InterruptedException e) {
 }

interrupt()를 호출한다는 것은 InterruptedException을 발생시킨다는 뜻(catch문을 실행시킨다는 뜻)으로
sleep()를 구현할 때는 꼭 try-catch문으로 구현해야 한다.

class thread_1 extends Thread{
            public void run() {
                for(int i = 0; i< 300; i++){
                    System.out.print("-");

                }
                System.out.print("th1 종료");
            }
        }

class thread_2 extends Thread{
            public void run() {
                for(int i = 0; i< 300; i++){
                    System.out.print("|");

                }
                System.out.print("th2 종료");
            }
        }

        thread_1 t1 = new thread_1();
        t1.start(); 

        thread_2 t2 = new thread_2();
        t2.start(); 

        try {
            t1.sleep(2000); // 2초동안 멈추게 한다.
            // Thread.sleep(2000)
        } catch (InterruptedException e) {
        }

        System.out.print("main 종료");


    }

    //결과 ... th1 종료 th2 종료 main 종료

결과로 t1이 2초 늦게 출력되어서 가장 마지막에 출력되길 기대하지만 결과는 t1이 가장 먼저 종료되었다.

그 이유는 sleep()은 항상 현재 실행 중인 쓰레드에 대해 작동하기 때문에 t1.sleep(2000)과 같이 호출하였어도 main메서드에 적용되어 main이 가장 늦게 종료된 것이다.

따라서 참조변수를 이용해서 호출하기보다는 Thread.sleep(2000)처럼 호출을 해야 한다.

뒤에 나오지만 yield()도 동일하다.

(2) interrupt()와 interrupted()

진행 중인 쓰레드의 작업을 취소하는 메서드 이다.

interrupt()가 호출되면 쓰레드에게 작업을 멈추라고 요청하며, interrupted상태를 변경한다.(false <-> true, false일 때는 쓰레드가 실행되고 true일때는 정지한다.)

void interrupt() 쓰레드의 interrupted상태를 false -> true로
boolean isInterrupted() 쓰레드의 interrupted 상태를 반환
static boolean interrupted() 현재 쓰레드의 interrupted상태를 반환 후, false로 변경

쓰레드가 sleep(), wait(), join()에 의해 '일시정지 상태'에 있을 때, interrupt()를 호출하면 InterruptedException이 발생하고 쓰레드는 '실행대기 상태(RUNNABLE)'로 바뀐다.

즉 멈춰있던 쓰레드를 깨워 실행가능한 상태로 만든다.

interrupt() 사용예제

class thread_1 extends Thread{
    public void run() {
       int i = 10;
       while(i != 0 && !isInterrupted()){
        System.out.println(i--);
        for(long x=0; x<2500000L; x++); //시간 지연
       }
       System.out.println("카운트 종료");
    }
}

thread_1 t1 = new thread_1();
        t1.start(); 

        String input = JOptionPane.showInputDialog("입력");
        System.out.println("입력 값은" + input );
        t1.interrupt(); // interrupted상태가 true가 된다.
        System.out.println(t1.isInterrupted());

사용자 입력이 끝나면 interrupt()에 의해 카운트다운이 중간에 멈춘다.

(3) yield()

yield()는 쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보(yield)한다.

우선순위가 낮은 코드의 경우 다른 스레드에 자원 할당을 위해 사용한다.

public class ThreadA extends Thread{

    boolean stop = false;
    boolean work = true;

    @Override
    public void run() {
        while (!stop){
            if(work){
             System.out.println("ThreadA -AAA");
                try {
                    Thread.sleep(500);
                } catch (InterruptedException ie){}
            } else{
                //다른 스레드에 실행 양보
                Thread.yield();
            }
        }
        System.out.println("ThreadA 종료");
    }
}

public class ThreadB extends Thread {

    boolean stop = false;
    boolean work = true;

    @Override
    public void run() {
        while (!stop){
            if(work){
        System.out.println("ThreadB - BBB");
                try {
                   Thread.sleep(500);
                } catch (InterruptedException ie){}
            } else{
                //다른 스레드에 실행 양보
                Thread.yield();
            }
        }
        System.out.println("ThreadB 종료");
    }
}

ThreadA a = new ThreadA();
ThreadB b = new ThreadB();

        a.start();
        b.start();

        try{
            Thread.sleep(2000);
        } catch (InterruptedException ie){ ie.getMessage(); }

//work값에 따라 다른 스레드에 실행을 양보하며 번갈아 실행된다.

        //a 일시정지
        a.work = false;

        try{
            Thread.sleep(1000);
        } catch (InterruptedException ie){ ie.getMessage(); }

        //a 실행 재개
        a.work = true;

        try{
            Thread.sleep(2000);
        } catch (InterruptedException ie){ ie.getMessage(); }

        //b 일시정지
        b.work = false;

        //a, b 실행종료
        a.stop = true;
        b.stop = true;

(4) join()

쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 사용

void join()
void join(long millis)
void join(long millis, int nanos)

시간을 지정하지 않으면, 해당 쓰레드가 작업을 모두 마칠 때까지 기다리게 된다.

주로 특정스레드의 실행값을 가져오기 위한 목적으로 사용한다.

예를 들어 특정 스레드에서 계산 작업을 맡고 있다면, 다른 스레드에서는 특정 스레드가 종료된 후에 접근하는 것이 안전하다.

join()sleep()처럼 interrupt()에 의해 대기상태에서 벗어날 수 있으므로, try-catch문을 사용해야 한다.

 static long startTime = 0; // static변수 설정

thread_1 t1 = new thread_1();
t1.start(); 

thread_2 t2 = new thread_2();
t2.start(); 

startTime = System.currentTimeMillis();

        try {
            t1.join(); //t1 작업이 끝날때까지 main쓰레드가 기다린다.
            t2.join(); //t2 작업이 끝날때까지 main쓰레드가 기다린다.
        } catch (InterruptedException e) {}

        System.out.println("소요시간:" + (System.currentTimeMillis() - test.startTime));
    }

}

class thread_1 extends Thread{
    public void run() {
       for(int i =0; i<300; i++){
        System.out.print(new String("-"));
       }

    }
}

class thread_2 extends Thread{
    public void run() {
        for(int i = 0; i< 300; i++){
            System.out.print(new String("|"));

        }
    }

    //결과
  ...(생략)소요시간:32

join()이 없었다면 main쓰레드는 바로 종료되었겠지만 작업을 마칠 때까지 기다리게 하여, 마지막에 소요시간을 출력할 수 있게 했다.

5. 쓰레드의 동기화

위에 언급했듯이 쓰레드의 동기화란 "한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것"이다.

동기화의 방법으로 한 쓰레드가 특정 작업을 마치기 전까지 방해받지 않도록 임계 영역(critical section)잠금(락, lock)을 설정하는 방법이 있다.

공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정하고, 공유 데이터(객체)가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 한다.

모든 코드를 수행한 후 lock을 반납하여 다른 쓰레드가 임계 영역에서 코드를 수행할 수 있게 한다.

(1) synchronized를 이용한 동기화

synchronized는 임계 영역을 설정하는 데 사용된다.

1. 메서드 전체를 임계 영역으로 지정
    public synchronized void test1() {
        ...
   }

2. 특정한 영역을 임계 영역으로 지정
    synchronized(객체의 참조변수) {
        ...
    }

첫 번째 방법에서 쓰레드는 synchronized메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환한다.

두 번째 방법은 메서드 내의 코드 일부를 블럭{}으로 감싸고 블럭 앞에 synchronized(참조변수)를 붙이는 것이다. 이때 참조변수는 락을 걸고자 하는 객체를 참조하는 것이어야 한다. 이를 synchronized블럭이라 부르며, 블럭의 영역 안에서는 lock을 얻고 벗어나면 lock을 반환한다.

Runnable r = new thread_1();
new Thread(r).start();
new Thread(r).start();

}
}
class Account {
    private int balance = 1000;

    public int getBalance() {
        return balance;
    }

    public void withdraw(int money){
        if(balance >= money){
            try {
                Thread.sleep(1000); // 상황을 만들기위해 
                일부로 다른 쓰레드에게 
                제어권을 넘겨주도록 설계
            } catch (InterruptedException e) {}
            balance -= money;
        }
    }
}

class thread_1 implements Runnable{
    Account acc = new Account();
    public void run() {
       while(acc.getBalance() > 0){
        int money = (int)(Math.random() * 3 +1) * 100;
        acc.withdraw(money);
        System.out.println("balance:" + acc.getBalance());
       }


    }
}

//결과
balance:600
balance:300
balance:200
balance:0
balance:-100

은행계좌(account)에서 잔고(balance)를 확인하고 임의의 금액을 출금(withdraw)하는 예제에서 잔고가 출금하려는 금액보다 큰 경우에만 출금이 되도록 구현되어 있다.

그러나 실행결과에서는 잔고가 음수가 찍히게 되는데, 이는 한 쓰레드가 if문의 조건식을 통과하고 출금하기 바로 직전에 다른 쓰레드가 끼어들어 출금을 먼저 했기 때문이다.

때문에 잔고를 확인하는 if문과 출금하는 문장은 임계 영역으로 묶어져야 한다.

 public synchronized void withdraw(int money){
        if(balance >= money){
            try {
                Thread.sleep(1000); 
            } catch (InterruptedException e) {}
            balance -= money;
        }
    }

 또는 

  public void withdraw(int money){
        synchronized(this){
        if(balance >= money){
            try {
                Thread.sleep(1000); 
            } catch (InterruptedException e) {}
            balance -= money;
        }
    }
 }

(2) wait()와 notify()

특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않게 하기 위한 메서드들이다.

동기화된 임계영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, wait()를 호출하여 쓰레드가 락을 반납하고 기다리게 한다.

그럼 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행 할 수 있게 되고, 추후에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서, 작업을 중단했던 쓰레드가 다시 락을 얻어 진행할 수 있게 된다.

wait()이 호출되면, 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다린다.

notify()가 호출되면, 해당 객체의 대기실에 있던 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받는다.

notifyAll()은 모든 쓰레드에게 통보를 하지만, 그래도 lock을 얻어 나오는 쓰레드는 하나이다.

wait()notify() 또는 notifyAll()이 호출될 때까지 기다린다.

매개변수가 있는 wait()는 지정된 시간이 지난 후에 자동적으로 notify()가 호출되는 것과 같이 행동한다.

waiting pool은 객체마다 존재하는 것으로 notifyAll()을 한다고 모든 객체의 waiting pool에 있는 쓰레드를 깨우는 것은 아니다.

wait(), notify(), notifyAll()
Object에 정의되어 있다.
동기화 블록(synchronized블록)내에서만 사용할 수 있다.
보다 효율적인 동기화를 가능하게 한다.

다음 예제는 식당에서 음식(Dish)을 만들어서 테이블(Table)에 추가(add)하는 요리사(Cook)와 테이블의 음식을 소비(remove)하는 손님(Customer)을 쓰레드로 구현한 것이다.

class Customer implements Runnable {
    private Table table;
    private String food;

    Customer(Table table, String food) {
        this.table = table;  
        this.food  = food;
    }

    public void run() {
        while(true) {
            try { Thread.sleep(100);} catch(InterruptedException e) {}
            String name = Thread.currentThread().getName();

            table.remove(food);
            System.out.println(name + " ate a " + food);
        } // while
    }
}

class Cook implements Runnable {
    private Table table;

    Cook(Table table) {    this.table = table; }

    public void run() {
        while(true) {
            int idx = (int)(Math.random()*table.dishNum());
            table.add(table.dishNames[idx]);
            try { Thread.sleep(10);} catch(InterruptedException e) {}
        } // while
    }
}

class Table {
    String[] dishNames = { "donut","donut","burger" }; // donut의 확률을 높였다.
    final int MAX_FOOD = 6; //테이블에 놓을 수 있는 최대 음식의 수
    private ArrayList<String> dishes = new ArrayList<>();

    public synchronized void add(String dish) {
        while(dishes.size() >= MAX_FOOD) {
                String name = Thread.currentThread().getName();
                System.out.println(name+" is waiting.");
                try {
                    wait(); // COOK쓰레드를 기다리게 한다.
                    Thread.sleep(500);
                } catch(InterruptedException e) {}    
        }
        dishes.add(dish);
        notify();  // 기다리고 있는 CUST를 깨우기 위함.
        System.out.println("Dishes:" + dishes.toString());
    }

    public void remove(String dishName) {

        synchronized(this) {    
            String name = Thread.currentThread().getName();

            while(dishes.size()==0) {
                    System.out.println(name+" is waiting.");
                    try {
                        wait(); // CUST쓰레드를 기다리게 한다.
                        Thread.sleep(500);
                    } catch(InterruptedException e) {}    
            }

            while(true) {
                for(int i=0; i<dishes.size();i++) {
                    if(dishName.equals(dishes.get(i))) {
                        dishes.remove(i);
                        notify(); // 잠자고 있는 COOK을 깨우기 위함 
                        return;
                    }
                } // for문의 끝

                try {
                    System.out.println(name+" is waiting.");
                    wait(); // 원하는 음식이 없는 CUST쓰레드를 기다리게 한다.
                    Thread.sleep(500);
                } catch(InterruptedException e) {}    
            } // while(true)
        } // synchronized
    }

    public int dishNum() { return dishNames.length; }
}

class ThreadWaitEx3 {
    public static void main(String[] args) throws Exception {
        Table table = new Table();

        new Thread(new Cook(table), "COOK1").start();
        new Thread(new Customer(table, "donut"),  "CUST1").start();
        new Thread(new Customer(table, "burger"), "CUST2").start();

        Thread.sleep(2000);
        System.exit(0);
    }
}

6. Lock과 Condition을 이용한 동기화

동기화할 수 있는 방법은 synchronized블럭 외에도 "java.util.concurrent.locks" 패키지가 제공하는 lock클래스들을 이용하는 방법이 있다.

synchronized블럭으로 동기화하면 자동적으로 lock이 걸리고 풀려서 편하다. synchronized블럭 내에서 예외가 발생해도 lock은 자동적으로 풀린다.

그러나 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 있다.

이럴 때 lock 클래스를 사용한다.

ReentrantLock : 재진입이 가능한 lock. 가장 일반적인 배타 lock
ReentrantReadWriteLock : 읽기에는 공유적이고, 쓰기에는 배타적인 lock
StampedLock : ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가

참고한 글들입니다.

 

[Java] 스레드(Thread) 제어하기 - 우선순위 설정, 동기화, 메서드 사용하기

스레드 우선순위 스레드는 우선순위를 할당할 수 있다. 스레드가 여러개인 경우 우선순위가 높은 스레드가 제어권을 가질 기회가 많아진다. 우선순위는 1~10까지 int 값으로 할당된다. 기본 우선

makecodework.tistory.com

 

[Java] 쓰레드 6 - 쓰레드 동기화(synchronized , Lock, Condition)

쓰레드의 동기화 멀티쓰레드 프로세스의 경우 여러 쓰레드가 자원을 공유해서 작업하기 때문에 서로에게 영향을 줄 수 있다. 이러한 일을 방지하기 위해 한 쓰레드가 진행중인 작업을 다른 쓰

cano721.tistory.com

 

한 번에 이해하는! CPU가 멀티태스킹을 하는 방법: 프로세스 VS 스레드

프로세스와 스레드에는 중요한 차이점이 있습니다. 바로 ‘메인 메모리를 어떻게 함께 사용하는가’입니다. 멀티 프로세싱에서 각각의 프로세스는 요리 탁자에 선을 긋듯 자기 영역을 명시해

hongong.hanbit.co.kr

 

JAVA 쓰레드란(Thread) ? - JAVA에서 멀티쓰레드 사용하기

JAVA에서 Thread사용하는 방법을 배우고 멀티코어 환경에서 멀티 쓰레드를 사용하는 방법을 알아보겠습니다.

honbabzone.com

잘못된 내용이 있다면 지적부탁드립니다. 방문해주셔서 감사합니다.

 

 

반응형

'개발공부 > Java' 카테고리의 다른 글

스트림  (0) 2023.03.31
람다식  (0) 2023.03.29
지네릭스(Generics)  (0) 2023.03.27
Arrays와 Collections  (0) 2023.03.24
컬렉션 프레임웍  (0) 2023.03.23