티스토리 뷰

스프링, 자바

자바의 스레드

killog 2021. 1. 16. 16:19
반응형

목표

자바의 멀티쓰레드 프로그래밍에 대해 학습하세요.

학습할 것 (필수)

  • Thread 클래스와 Runnable 인터페이스
  • 쓰레드의 상태
  • 쓰레드의 우선순위
  • Main 쓰레드
  • 동기화
  • 데드락

마감일시

2021년 1월 23일 토요일 오후 1시까지.


스레드란?

JVM 이 시작되면, JAVA 프로세스가 시작란다. 이 프로세스라는 울타리 안에서 여러 쓰레드가 아둥바둥 살게됩니다. 어느 프로세스 간에 쓰레드가 하나 이상 수행됩니다.

JVM 은 기본적으로 아무런 옵션 없이 실행하면, os 마다 다르지만, 적어도 32MB ~ 64MB 의 물리 메모리를 점유합니다. 이에 반해, 쓰레드를 하나 추가하면, 1MB 이내의 메모리를 점유합니다. 이러한 이유로, 쓰레드를 경량 프로세스, 실행 컨텍스트(Execution Context)라 부른다. ( 자바의 신 25장. )

스레드 자체의 의미

사전적 의미로 이야기의 흐름, 줄기, 실타래를 의미한다. (Thread)

하나의 프로세스 내에서 더 작은 단위로 독립적으로 실행시키며, 제어가 가능한 흐름을 의미합니다.

( 참고 이미지: https://badcandy.github.io/2019/01/14/concurrency-01/)

참고: 자바 스레드와 하드웨어적 스레드 구분

( 내 CPU는 n코어 m 스레드인데 100개 이상의 스레드?! (출처: https://kldp.org/node/154708))

병렬성(Parallelism)동시성(Concurrency)이라는 개념을 확인해봅시다.

병렬성은 작업들이 병렬로 실행되는 성질을 의미하는 것입니다.

동시성은 여러개의 작업들이 짧은 시간내에 번갈아 가면서 병렬로 처리되는 것처럼 보이도록 실행되는 성질을 의미하는 것입니다.

자바 스레드는 동시성을 가지고 있기 때문에 하드웨어적 스레드 개수보다 더 많은 스레드를 생성하여 하드웨어적 스레드를 번갈아 가면서 사용하여 작업을 처리합니다.

(최적의 스레드 개수는 하드웨어적 상황, 스레드 차지 메모리 상황등 따져봐야하는 것이 많이 때문에 개발자 판단이 필요합니다.)


Thread 클래스와 Runnable 인터페이스

자바에서 Thread를 만드는 방법은 크게 Thread 클래스를 상속받는 방법과 Runnable인터페이스를 구현하는 방법이 있습니다.

[Thread를 상속 받아서 쓰레드를 생성하는 방법]

  • java.lang.Thread클래스를 상속받습니다. 그리고 Thread가 가지고 있는 run()메소드를 오버라이딩합니다.
  • 쓰레드를 생성하고, Thread 클래스가 가지고 있는 start() 메소드를 호출 합니다.
  • 모든 스레드에는 우선순위가 있습니다. 우선순위에 따라 우선순위가 높은 순부터 스레드가 실행됩니다.
  • 각 스레드는 데몬이거나, 데몬이지 않을 수 있습니다.
  • 스레드에서 실행중인 코드가 새 스레드 객체를 생성할 때, 새 스레드는 처음에 생성 스레드의 우선순위와 동일하게 설정된 우선순위를 가집니다.
  • 스레드에서 실행중인 코드가 새 스레드 객체를 생성할 때, 새 스레드는 처음에 생성 스레드가 데몬인 경우, 새 스레드도 데몬을 유지한다.
package me.whiteship.oracleThreadEx;

public class PrimeThread extends Thread {
  int minPrime;

  public PrimeThread(int minPrime) {
    this.minPrime = minPrime;
  }

  public static void main(String[] args) {
    PrimeThread newThread = new PrimeThread(2);
    newThread.start();
  }

  @Override
  public void run() {
    // compute primes larger than minPrime
    // 출처 : https://www.geeksforgeeks.org/kth-prime-number-greater-than-n/
    GFG.sieve();
    GFG.computePrimeGreaterThanMinPrime(minPrime);
  }
}

[Runnable인터페이스를 구현해서 쓰레드를 만드는 방법]

  • Runnable 인터페이스가 가지고 있는 run()메소드를 구현합니다.
  • Thread를 생성하고, 해당 생성자에 PrimeThread를 넣어서 Thread를 생성합니다.
  • 쓰레드를 생성하고, Thread 클래스가 가지고 있는 start() 메소드를 호출 합니다.
package me.whiteship.oracleThreadEx;

public class PrimeRun implements Runnable {
  int minPrime;

  public PrimeRun(int minPrime) {
    this.minPrime = minPrime;
  }

  public static void main(String[] args) {
    PrimeRun primeRun = new PrimeRun(11);
    new Thread(primeRun).start();
  }

  @Override
  public void run() {
    // compute primes larger than minPrime
    GFG.sieve();
    GFG.computePrimeGreaterThanMinPrime(this.minPrime);
  }
}

Q. 왜 우리는 run()을 구현하고 start()로 스레드를 시작할까?

자바는 start() 함수를 호출하면, run() 메소드를 수행하게 되어있다. start() 메소드를 통해 시작했다는 것은 , 프로세스가 아닌 하나의 스레드를 JVM 에 추가해 실행한다는 것이다. 생성된 스레드는 run()메소드가 종료되면 끝난다.

Q.Thread vs Runnable Interface

A. 자바는 다중상속이 불가능하기 때문에, 쓰레드 클래스가 다른 클래스를 확장할 필요가 있을 경우, 인터페이스를 구현, 그 외에는 쓰레드 클래스를 사용하는 것이 편하다.

데몬스레드란?

데몬(Daemon)이란 보통 리눅스와 같은 유닉스계열의 운영체제에서 백그라운드로 동작하는 프로그램을 말합니다.

  • 데몬쓰레드를 만드는 방법은 쓰레드에 데몬 설정을 하면 됩니다.

    • 이런 쓰레드는 자바프로그램을 만들 때 백그라운드에서 특별한 작업을 처리하게 하는 용도로 만듭니다.
  • 데몬쓰레드는 일반 쓰레드(main 등)가 모두 종료되면 강제적으로 종료되는 특징을 가지고 있습니다.

    설정방법 : thread.setDaemon(true);


// 데몬, 우선 순위 알아보기한 코드
package me.whiteship.oracleThreadEx;

public class ThreadEx {
  public static void main(String[] args) {
    PrimeThread p1 = new PrimeThread(143);
    p1.setPriority(Thread.MAX_PRIORITY);
    p1.setDaemon(true);
    p1.start();

    PrimeThread p2 = new PrimeThread(2);
    p2.setPriority(Thread.MIN_PRIORITY);
    p2.start();
  }
}
package me.whiteship.oracleThreadEx;

public class PrimeThread extends Thread {

  long minPrime;

  public PrimeThread(long minPrime) {
    this.minPrime = minPrime;
  }

  public static void main(String[] args) {

    PrimeThread p = new PrimeThread(143);
    p.start();
  }

  @Override
  public void run() {
    // compute primes larger than minPrime
    // 출처 : https://www.geeksforgeeks.org/kth-prime-number-greater-than-n/
    if (minPrime == 5) {
      return;
    } else if (minPrime < 150) {
      GFG.sieve();

      PrimeThread newP = new PrimeThread(GFG.computePrimeGreaterThanMinPrime((int) this.minPrime));
      System.out.println(
          newP.getName()
              + "스레드의 우선순위:"
              + newP.getPriority()
              + ", 값: "
              + newP.minPrime
              + " 데몬여부:"
              + newP.isDaemon());
      newP.start();
    }
  }
}

Q. 자식스레드의 우선순위와 데몬 여부는 지속될까?

A. 지속된다. 이것을 보이기 위해, 149(151), 3(5) 스레드를 가지고 구현해보았다. 처음에 생성 스레드의 우선순위와 동일하게 설정된 우선순위를 가지고, 데몬이 유지되는 것을 확인했다. 다만, 형제 스레드가 먼저 구현되는 것을 확인했다.

  • JVM 이 시작될떄, 일반적으로 비데몬 스레드가 있슺니다. JVM 은 다음 중 하나가 발생할 때까지 스레드를 계속 호출합니다.

스레드 우선순위

스레드는 우선순위를 지정할 수 있다. 우선순위에 따라 특정 스레드가 더 많은 시간동안 작업하게 할 수 있다.

필드 설명
static int MAX_PRIORITY 스레드가 가질 수 있는 최대 우선순위
static int MIN_PRIORITY 스레드가 가질 수 있는 최소 우선순쉬
static int NORM_PRIORITY 스레드가 생성될 때 가지는 기본 우선순위
  • 스레드 우선순위 할당

setPriority 메소드를 활용함.

  • 스레드 우선순위

getPriority메소드 활용

쓰레드가 우선순위를 가질 수 있는 범위는 1부터 10까지 이고, 숫자가 높을 수록 우선순위가 높아진다.

단, 스레드 우선순위는 비례적 절대값이 아닌 상대값이다. 우선순위 10이 우선순위 1에 비해 10배의 시간을 할당 받는 것이 아닌, 상대적으로 실행 큐에 자주 포함되는 것이다.

main() 메소드를 실행하는 쓰레드의 우선순위는 언제나 5이다.

main() 메소드 내에서 생성된 쓰레드 Thread-0 의 우선순위는 5로 설정되는 것을 확인가능하다. ( 아직 이해 안감. )


Main 스레드

자바는 JVM 위에서 돌아간다. 이것이 프로세스이고, JAVA 를 실행하기 위해 우리가 실행하는 main() 메소드가 메인쓰레드이다.

public class MainThread {
    public static void main(String[] args) { // 메인 스레드의 시작점 선언

    }
}

다른 스레드 없이 main 메소드만 실행하는 것을 싱글 스레드 어플리케이션이라한다. 다른 스레드를 생성할 경우, 멀티 스레드 어플리케이션이라 한다.


동기화 메소드와 동기화 블록

1. 자바 동기화 블록

1.1. Syntax

동기화 된 블록을 작성하는 일반적인 구문은 다음과 같습니다. 여기서 lockObject 는 동기화 된 명령문이 나타내는 잠금이 연관되는 객체에 대한 참조입니다.

synchronized( lockObject ) 
{
   // synchronized statements
}

1.2. Internal working

스레드가 동기화 된 블록 내에서 동기화 된 명령문을 실행하려면 lockObject의 모니터 에서 잠금을 획득해야합니다 . 한 번에 하나의 스레드 만 객체의 사용권(Monitoring Lock) 을 얻을 수 있습니다. 따라서 다른 모든 스레드는이 스레드가 현재 잠금을 획득하고 실행이 완료 될 때까지 기다려야합니다.

이러한 방식으로 synchronized키워드는 한 번에 하나의 스레드 만 동기화 된 블록 명령문을 실행하도록 보장하므로 여러 스레드가 블록 내의 공유 데이터를 손상시키는 것을 방지합니다.

스레드가 sleep 상태가되면 ( sleep()메소드 사용 ) 잠금이 해제되지 않습니다. 이 휴면 시간에는 동기화 된 블록 문을 실행하는 스레드가 없습니다.

만약 'synchronized (lock)' 에서 쓰인 locknull인 경우, NullPointerException이 발생합니다.

1.3. Java synchronized block example

public class MathClass 
{
    void printNumbers(int n) throws InterruptedException 
    {
        synchronized (this) 
        {
            for (int i = 1; i <= n; i++) 
            {
                System.out.println(Thread.currentThread().getName() + " :: "+  i);
                Thread.sleep(500);
            }
        }
    }
}
package me.whiteship.oracleThreadEx;

public class Main {
  public static void main(String[] args) {

    final MathClass mathClass = new MathClass();
    // first thread
    Runnable r =
        new Runnable() {
          @Override
          public void run() {
            try {
              mathClass.printNumbers(2);
            } catch (InterruptedException e) {
              e.printStackTrace();
            }
          }
        };
    new Thread(r, "ONE").start();
    new Thread(r, "TWO").start();
  }
}

2.JAVA 동기화 method

2.1. Syntax

동기화 된 메서드를 작성하는 일반적인 구문은 다음과 같습니다. 여기서 lockObject 는 동기화 된 명령문이 나타내는 모니터와 잠금이 연관되는 객체에 대한 참조입니다.

<access modifier> synchronized method( parameters ) 
{
    // synchronized code
}

2.2. Internal working

동기화된 블록과 유사하게 스레드는 동기화된 메소드를 사용해서, 객체에 대한 권한을 획득해야한다.

  • '.class'객체 – 메서드가 정적 인 경우.
  • 'this'객체 – 메서드가 정적이 아닌 경우. 'this'는 동기화 된 메서드가 호출되는 현재 개체에 대한 참조를 나타냅니다.

2.3. 자바 동기화방법 예

public class MathClass 
{
    synchronized void printNumbers(int n) throws InterruptedException 
    {
        for (int i = 1; i <= n; i++) 
        {
            System.out.println(Thread.currentThread().getName() + " :: "+  i);
            Thread.sleep(500);
        }
    }
}

일종의 동기화 예시ㅣ뮤직박스

예를 들어, 뮤직박스에 노래가 3종류가 있습니다. 하나는 새소리, 하나는 사람 노래, 하나는 개소리입니다.

만약, 사람(플레이어)가 노래를 3개를 트는 경우, 새 + 사람+ 개 소리가 섞이면, 아무 노래도 듣지 못하고, 소음만 듣게 됩니다.

이것을 한정된 공유자원을 이용하려고 하는 스레드들로 생각해보면 됩니다.

이럴때, 해결 방법은 모든 노래에 synchronized 를 사용하면, 스레드들이 순서를 기다려 노래를 재생하게됩니다.

package me.whiteship.oracleThreadEx;

public class MusicBox {

  public synchronized void playMusic(String bark) {
    for (int i = 0; i < 3; i++) {
      System.out.println(bark);
      try {
        Thread.sleep((int) (Math.random() * 1000));
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    } 
  } 
}
package me.whiteship.oracleThreadEx;

public class MusicPlayer extends Thread {
  int type;
  MusicBox musicBox;
  // 생성자로 부터 musicBox와 정수를 하나 받아들여서 필드를 초기화
  public MusicPlayer(int type, MusicBox musicBox) {
    this.type = type;
    this.musicBox = musicBox;
  }

  public static void main(String[] args) {
    // MusicBox 인스턴스
    MusicBox box = new MusicBox();

    MusicPlayer bird = new MusicPlayer(1, box);
    MusicPlayer person = new MusicPlayer(2, box);
    MusicPlayer dog = new MusicPlayer(3, box);

    // MusicPlayer쓰레드를 실행합니다.
    bird.start();
    person.start();
    dog.start();
  }
  // type이 무엇이냐에 따라서 musicBox가 가지고 있는 메소드가 다르게 호출
  public void run() {
    switch (type) {
      case 1:
        musicBox.playMusic("짹");
        break;
      case 2:
        musicBox.playMusic("랄");
        break;
      case 3:
        musicBox.playMusic("멍");
        break;
    }
  }
}

Q. 스레드의 우선순위를 통해 같은 기능을 수행할 수 없나요?

자바의 스레드는 동시성을 가지고 있습니다. 우선순위를 높이면, 먼저 수행될뿐 synchronized 가 되지 않는 이상, 일정 시간이 되면, 다른 스레드에게 자리를 빼앗깁니다 .

    MusicBox box = new MusicBox();
    MusicPlayer bird = new MusicPlayer(1, box);
    MusicPlayer person = new MusicPlayer(2, box);
    MusicPlayer dog = new MusicPlayer(3, box);
    dog.setPriority(10);
    person.setPriority(2);
    bird.setPriority(1);
    // MusicPlayer쓰레드를 실행합니다.
    bird.start();
    person.start();
    dog.start();


스레드의 상태

동시성은 여러개의 작업들이 짧은 시간내에 번갈아 가면서 병렬로 처리되는 것처럼 보이도록 실행되는 성질을 의미하는 것입니다.

자바 스레드는 동시성을 가지고 있기 때문에 하드웨어적 스레드 개수보다 더 많은 스레드를 생성하여 하드웨어적 스레드를 번갈아 가면서 사용하여 작업을 처리합니다.

  • 쓰레드는 실행가능상태인 Runnable과 실행상태인 Running상태로 나뉩니다.
  • 실행되는 쓰레드 안에서 Thread.sleep()이나 Object가 가지고 있는 wait()메소드가 호출이 되면 쓰레드는 Blocked상태가 됩니다.
  • Thread.sleep()은 특정시간이 지나면 자신 스스로 블록상태에서 빠져나와 Runnable이나 Running상태가 됩니다.
  • Object가 가지고 있는 wait()메소드는 다른 쓰레드가 notify()notifyAll()메소드를 호출하기 전에는 블록상태에서 해제되지 않습니다.
  • wait()메소드는 호출이 되면 모니터링 락을 놓게 됩니다. 그래서 대기중인 다른 메소드가 실행합니다.
  • 쓰레드의 run메소드가 종료되면, 쓰레드는 종료됩니다. 즉 Dead상태가 됩니다.
  • Thread의 yeild메소드가 호출되면 해당 쓰레드는 다른 쓰레드에게 자원을 양보하게 됩니다.
  • Thread가 가지고 있는join메소드를 호출하게 되면 해당 쓰레드가 종료될 때까지 대기하게 됩니다.

(프로그래머스)


쓰레드와 상태제어(join)

join() 메소드는 다른 스레드들에게, join에 해당하는 스레드가 멈출 때까지 기다리게 합니다.

예시) 링딩동 노래를 들을 수 있는 주크박스를 만들어보자.

package me.whiteship.oracleThreadEx;

public class RingDingDong extends Thread {
  public static void main(String[] args) {
    RingDingDong player = new RingDingDong();
    System.out.println("링딩동 노래를 켭니다.");
    player.start();
    System.out.println("링딩동 노래가 끝났습니다.");
  }

  @Override
  public void run() {
    String lyrics = "링딩동링딩동링디기딩디기딩딩딩";
    for (char lyric : lyrics.toCharArray()) {
      System.out.println(lyric);
      try {
        Thread.sleep(50);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }
}

"링딩동 노래를 켭니다"➡"링딩동링딩동링디기딩디기딩딩딩"➡"링딩동 노래가 끝났습니다." 를 하기 위해서는 player 스레드가 종료될때까지 기다린 후 내용을 출력하게 해야한다.

이때, 사용하는 것이 join() 함수이다.

( 원하는 것과 다른 출력문 )

// join() 의 사용. 
    RingDingDong player = new RingDingDong();
    System.out.println("링딩동 노래를 켭니다.");

    player.start();
    try {
      player.join();
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    System.out.println("링딩동 노래가 끝났습니다.");

join() 메소드는 다른 스레드들에게, join에 해당하는 스레드가 멈출 때까지 기다리게 합니다.

또한, 반드시 InterruptedException을 처리하기 위한 try 문으로 감싸줘야한다.


쓰레드와 상태제어(wait, notify,notifyAll)

waitnotify는 동기화된 블록안에서 사용해야 한다. wait를 만나게 되면 해당 쓰레드는 해당 객체의 모니터링 락에 대한 권한을 가지고 있다면 모니터링 락의 권한을 놓고 대기한다.

1. wait()

호출 스레드에게 잠금을 포기하고 다른 스레드가 동일한 모니터에 들어가 notify()를 호출 할 때까지 절전 모드로 전환하도록 지시합니다 . 이 wait()방법은 실제로 동기화 메커니즘에서 직접 사용할 수없는 기능을 사용하여 synchronization lock과 통합됩니다.

synchronized( lockObject )
{ 
    while( ! condition )
    { 
        lockObject.wait();
    }
    //take the action here;
}

2. notify()

동일한 개체에서 wait()라는 단일 스레드를 깨웁니다.notify() 호출은 실제로 리소스에 대한 잠금을 해제하지 않습니다. 대기 중인 스레드에 해당 스레드가 wake up될 수 있음을 나타냅니다. 그러나 알림의 동기화된 블록이 완료될 때까지 잠금이 실제로 해제되지 않습니다. 따라서, notify()이후에 10초간 더 행위를 해야할 경우, 스레드는 notify() 호출 되었을 지라도, 추가적으로 10초를 더 기다려야한다.

synchronized(lockObject) 
{
    //establish_the_condition;

    lockObject.notify();

    //any additional code if needed
}

3.notifyAll()

동일한 개체에 대해 wait()라고 하는 모든 스레드를 깨웁니다. 대부분의 상황에서는 보장되지는 않지만 가장 높은 우선 순위 스레드가 먼저 실행됩니다. 기타는 위의 notify() 방법과 동일합니다.

synchronized(lockObject) 
{
    establish_the_condition;

    lockObject.notifyAll();
}

Q. notify() 함수를 실행했을 때, 어떤 스레드도 waiting 상태가 아니라면?

A.일반적으로 이러한 방법을 올바르게 사용하는 경우 대부분의 시나리오에서는 그렇지 않습니다. 다른 스레드가 대기 중이 아닐 때 notify() 메서드가 호출되더라도 notify() 은 반환되고 알림은 손실됩니다.대기 및 알림 메커니즘은 알림을 보내는 조건을 모르기 때문에 대기 중인 스레드가 없는 경우 알림이 수신되지 않는 것으로 가정합니다.나중에 wait()메서드를 실행하는 스레드는 다른 알림이 발생할 때까지 기다려야 합니다.

Q. notify() 함수를 여러 wait() 스레드들이 기다리고 있었다면?

A.그것은 여러 가지 요인에 달려 있습니다.Java 사양에서 알림을 받는 스레드를 정의하지 않습니다. 런타임에서 실제로 알림을 수신하는 스레드는 Java 가상 시스템의 구현, 프로그램 실행 중 스케줄링 및 타이밍 문제 등 몇 가지 요인에 따라 달라집니다.단일 프로세서 플랫폼에서도 여러 스레드 중 어떤 스레드가 알림을 수신하는지 확인할 수 없습니다.


쓰레드와 상태제어(sleep)

  • 스레드를 쉰다.내부 인자로 int 를 받는다.
  • 예) Thread.sleep((int)(Math.random() * 1000));

데드락(=교착 상태)

  • 데드락이란, 둘 이상의 스레드가 lock을 획득하기 위해 대기하고 있는데, 이 lock 을 잡고 있는 스레드들도 똑같이 다른 lock을 기다리면서 서로 block을 기다리면서 서로 block상태에 놓이는 것을 말한다. 데드락은 다수의 쓰레드가 같은 lock 을 동시에, 다른 명령에 의해 획득하려할 때 발생한다.

A가 a락 을 소지하고, B가 b락을 소지할 때, A가 b 락을 기다리고, B 가 a락을 기다리면 발생한다.

package me.whiteship.thread;

public class DeadLock {
  public static final Object lockA = new Object();
  public static final Object lockB = new Object();

  public static void main(String[] args) {
    A aThread = new A();
    B bThread = new B();
    bThread.start();
    aThread.start();
  }

  static class A extends Thread {
    @Override
    public void run() {
      synchronized (lockA) {
        System.out.println("DeadLock A: holding lock a,,");

        try {
          Thread.sleep(10);
        } catch (InterruptedException e) {

        }
        System.out.println(" A: Waiting for lock b...");
        synchronized (lockB) {
          System.out.println("DeadLock A: holding lock a,b");
        }
      }
    }
  }

  static class B extends Thread {
    @Override
    public void run() {
      synchronized (lockB) {
        System.out.println("DeadLock B: holding lock b,,");

        try {
          Thread.sleep(10);
        } catch (InterruptedException e) {
        }
        System.out.println(" B: Waiting for lock a...");
        synchronized (lockA) {
          System.out.println("DeadLock B: holding lock a,b,");
        }
      }
    }
  }
}
// 수행결과
DeadLock B: holding lock b,,
DeadLock A: holding lock a,,
 B: Waiting for lock a...
 A: Waiting for lock b...


참고 문헌

자바의 신

https://badcandy.github.io/2019/01/14/concurrency-01/

http://www.ktword.co.kr/abbr_view.php?m_temp1=2299

https://raccoonjy.tistory.com/15
https://programmers.co.kr/learn/courses/9/lessons/279

https://badcandy.github.io/2019/01/14/concurrency-01/

http://www.ktword.co.kr/abbr_view.php?m_temp1=2299

https://raccoonjy.tistory.com/15

https://howtodoinjava.com/java/multi-threading/wait-notify-and-notifyall-methods/

https://sujl95.tistory.com/63

반응형

'스프링, 자바' 카테고리의 다른 글

프로젝트 환경 설정  (0) 2021.01.19
[intelly j ] spring 초기 세팅 모음집  (0) 2021.01.18
자바 예외 처리  (0) 2021.01.16
자바의 인터페이스  (0) 2021.01.03
7주차 과제: 패키지  (0) 2020.12.27
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함