티스토리 뷰
📖 Java의 특징
객체지향 언어
- 객체지향 언어의 특징인 캡슐화, 상속, 다형성을 지원한다.
- 객체를 만들기 위한 설계도인 클래스를 작성하고, 객체간의 연결을 통해 목적에 맞게 프로그램을 만든다.
모든 운영체제에서 실행 가능
- JVM을 사용하기 때문에 자바 실행 환경이 설치되어 있는 모든 운영체제에서 실행 가능하다.
하이브리드 언어
- 컴파일 언어인 동시에 인터프리터 언어로 작성한 코드를 컴파일하여 이진 파일을 만든 후, 자바 런타임이 이진 파일을 인터프리트하며 실행된다.
- 자바는 컴파일 언어에 가까운 속도와 시스템 독립성을 가진다.
메모리를 직접 관리
- 개발자가 직접 메모리에 접근하지 않을 때, 자바가 직접 메모리를 관리한다.
- 예를들어 객체 생성 시 자동으로 메모리 영역을 찾아 할당하고, GC를 실행시켜 사용하지 않는 객체를 자동으로 제거한다.
오픈소스 라이브러리의 풍부함
- 자바는 오픈소스 언어이고 자바 프로그램에서 사용하는 라이브러리 또한 오픈소스의 양이 방대하여, 고급 기능을 구현하는 코드를 작성하는 대신 검증된 오픈소스 라이브러리를 사용할 수 있다.
멀티 스레드 지원
- 자바에서 개발되는 멀티 스레드 프로그램은 시스템과 관계없이 구현이 가능하며, 관련된 라이브러리가 제공되므로 구현이 쉽다.
- 여러 스레드에 대한 스케줄링을 자바의 인터프리터가 담당하게 되어 멀티 스레드 기능을 다른 언어에 비해 비교적 쉽게 사용할 수 있다.
동적 로딩 지원
- 여러 클래스로 구성된 자바 프로그램은 실행 시에 모든 클래스가 로딩되는 것이 아닌, 프로그램 실행 중 필요한 시점에 클래스를 로딩하여 사용할 수 있다.
📖 Java의 단점
- 성능: JVM 위에서 동작하기 때문에 오버헤드가 발생하고, JIT 컴파일러의 성능 문제가 존재한다.
- 메모리 소비: GC가 자동으로 메모리를 관리하는 과정에서 오버헤드가 발생할 수 있다. 또한, 객체지향 언어이기 때문에 객체를 생성하고 관리하는 과정에서 불필요한 객체 생성이 발생하는 경우가 있다.
- 복잡한 문법: 너무 많은 라이브러리와 API가 존재하기 때문에 적절한 사용법을 익히는 것이 어렵다.
📖 Java의 실행 과정
- 컴파일 단계: javac 컴파일러가 .java 소스 코드를 분석하고 바이트코드로 변환하여 .class 파일을 생성한다.
- 클래스 로딩 단계: JVM의 클래스 로더가 .class 파일을 읽고 런타임 메모리에 적재한다.
- 실행 단계: JVM의 실행 엔진(Execution Engine)이 클래스 로더가 적재한 바이트코드를 실행한다.
📖 인터프리터 vs JIT 컴파일 방식
인트프리터 방식
- 바이트코드를 한 줄씩 해석하고 실행하기 때문에 실행 속도가 느림
- 실행 즉시 결과를 볼 수 있음
- JavaScript, Python 등
JIT 컴파일러 방식
- 바이트코드를 한꺼번에 기계어로 변환한 후 실행
- 처음 실행할 때는 변환 시간이 필요하지만, 이후에는 빠르게 실행됨
- 자주 실행되는 코드를 캐싱하여 최적화 수행
인터프리터 방식은 빠르게 실행할 수 있지만, 반복적인 코드 실행에서는 JIT 컴파일러가 훨씬 효율적이다. Java는 두 방식을 혼합하여 사용하며, 성능을 최적화하기 위해 자주 실행되는 코드를 JIT 컴파일러가 자동으로 변환하여 실행 속도를 높인다.
📖 JDK & JRE
JDK(Java Development Kit)
- 자바 개발 키트의 약자로, 자바 프로그램을 개발하고 실행하는 데 필요한 도구 모음이다.
- JDK에는 컴파일러(javac), 디버거, JAR 생성 도구 등 개발에 필요한 프로그램과 자바 실행 환경(JRE)이 포함되어 있다.
JRE(Java Runtime Environment)
- 자바 실행 환경의 약자로, JVM 실행에 필요한 라이브러리들을 포함한 환경이다.
- JRE가 있으면 자바 애플리케이션을 실행할 수 있지만, 개발 도구(javac 같은 컴파일러)는 포함되지 않는다. JDK 11부터는 JRE가 따로 배포되지 않고, JDK에 포함되어 있다.
📖 equals()와 ==
String str1 = "java"; //리터럴로 선언
String str2 = "java";
String str3 = new String("java"); //new 연산자로 선언
String str4 = new String("java");
문자열 리터럴을 사용하는 경우, 자바 컴파일러는 String Constant Pool이라는 영역에 같은 값의 문자열을 공유하여 메모리 사용량을 최적화한다. 따라서 str1과 str2의 주소값은 같다.
반면 new 연산자를 사용하여 새로운 문자열 객체를 생성하는 경우, Heap 영역에 저장되며 다른 주소값을 할당받는다. str1과 str3의 주소값은 다르며, str3과 str4의 주소값도 다르다.
== 연산자(동일성)
두 문자열의 주소(참조)값이 같은지 비교한다. 다시 말해 두 객체가 메모리에서 동일한 위치를 가리키는지를 확인한다.
String str1 = "java"; //리터럴로 선언
String str2 = "java";
String str3 = new String("java"); //new 연산자로 선언
System.out.println(str1 == str2); //true
System.out.println(str1 == str3); //false
문자열 리터럴을 사용한 str1과 str2는 주소값이 같으므로 == 비교는 true를 리턴한다.
new 연산자로 생성한 문자열 객체 str3은 주소값이 다르므로 == 비교는 false를 리턴한다.
equals() 메서드(동등성)
두 문자열의 내용을 비교한다. 두 문자열이 같은 값을 가지고 있는지 확인하는 메서드이다.
String str1 = "java";
String str2 = "java";
String str3 = new String("java");
System.out.println(str1.equals(str2)); //true
System.out.println(str1.equals(str3)); //true
str1, str3은 주소값이 다르더라도 문자열의 내용이 같으므로 equals() 메서드로 비교하면 true를 리턴한다.
📖 toString()
toString() 메서드는 자바의 Object 클래스에서 제공하는 메서드로, 객체를 문자열(String) 형태로 변환하는 역할을 한다. 모든 Java 클래스는 Object를 상속받기 때문에, 모든 객체에서 toString() 메서드를 사용할 수 있다.
기본 동작
기본적으로 Object의 toString()은 클래스 이름 + @ + 해시코드를 반환한다.
class Sample {}
public class Main {
public static void main(String[] args) {
Sample obj = new Sample();
System.out.println(obj.toString());
}
}
[출력]
Sample@5e2de80c -> 클래스 이름과 객체의 해시코드가 포함된 문자열
toString()을 오버라이딩하는 이유
기본 toString() 메서드는 객체의 정보를 직관적으로 보여주지 못한다. 따라서, 우리가 원하는 정보를 반환하도록 오버라이딩하여 사용하는 것이 일반적이다.
class Person {
String name;
int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// toString() 오버라이딩
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";
}
}
public class Main {
public static void main(String[] args) {
Person p = new Person("Alice", 25);
System.out.println(p.toString()); // 명시적 호출
System.out.println(p); // 자동 호출
}
}
[출력]
Person{name='Alice', age=25} //명시적 호출
Person{name='Alice', age=25} //자동 호출
System.out.println(p);에서 toString()을 직접 호출하지 않았지만, println()은 내부적으로 toString()을 자동으로 호출하기 때문에 같은 결과가 나온다.
📖 원시 타입과 참조 타입
원시 타입
정수, 실수, 문자, 논리 리터럴 등 실제 데이터 값을 저장하는 타입이다. int a = 10; 과 같이 코드를 작성했다면 정수 값이 할당될 수 있는 a라는 이름의 메모리 공간이 JVM 스택 영역에 생성되고, 10이라는 값이 들어간다.
참조 타입
기본타입을 제외한 타입으로, 객체의 주소를 저장하는 타입이다. 문자열, 배열, 클래스, 인터페이스 등이 있다. Java에서 실제 객체는 JVM 힙 영역에 저장되며, 참조 타입 변수는 실제 객체의 주소를 JVM 스택 영역에 저장한다. 그리고 객체를 사용할 때마다 참조 변수에 저장된 객체의 주소를 불러와 사용하게 된다.
Person p = new Person(); 이라는 코드를 작성했다면 p라는 이름의 메모리 공간이 스택 영역에 생성되고, 생성된 p의 인스턴스는 힙 영역에 생성된다. 즉, 스택 영역에 생성된 참조 변수 p는 힙 영역에 생성된 p의 인스턴스 주소 값을 가지게 된다.
📖 Call by Value & Call by Reference
Call by Value(값에 의한 호출)
- 메서드에 인자로 전달된 변수의 복사본이 생성됨
- 원본 변수의 값은 변경되지 않는다.
public class Main {
public static void changeValue(int num) {
num = 100; // num 값 변경
}
public static void main(String[] args) {
int x = 10;
changeValue(x);
System.out.println(x); //여전히 10 (변경되지 않음)
}
}
Call by Reference(참조에 의한 호출)
- 원본 객체의 주소가 전달되므로, 메서드 안에서 변경하면 원본도 변경됨
- 자바에서는 기본적으로 Call by Value만 지원하지만, 객체는 참조형이기 때문에 마치 Call by Reference처럼 동작할 수 있다.
class Person {
String name;
public Person(String name) {
this.name = name;
}
public static void changeName(Person p) {
p.name = "Changed"; // 객체 내부 값 변경
}
public static void main(String[] args) {
Person person = new Person("Alice");
changeName(person);
System.out.println(person.name); // "Changed" (변경됨)
}
}
📖 오버로딩과 오버라이딩
오버로딩
오버로딩은 메서드 오버로딩과 생성자 오버로딩이 있으며 실제 적용되는 것은 같다. 같은 이름의 함수(메서드)를 여러 개 정의하고, 매개변수의 유형과 개수를 다르게 하여 다양한 유형의 호출에 응답할 수 있도록 하는 방식이다.
(일반적으로 하나의 클래스 안에 같은 이름의 메서드를 정의하게 되면 에러가 발생한다.)
public class Test {
// 매개변수가 없는 overloadingTest() method
void overloadingTest(){
System.out.println("매개변수를 받지 않는 메서드");
}
// 매개변수로 int형 인자 2개
void overloadingTest(int a, int b){
System.out.println("int형 인자 2개를 요청하는 메서드 "+ a + ", " + b);
}
// 매개변수로 String형 인자 1개
void overloadingTest(String str){
System.out.println("String형 인자 1개를 요청하는 메서드 " + d);
}
}
오버라이딩
오버라이딩은 상위 클래스로부터 상속받은 메서드의 동작만을 재정의하는 것이다. 상위 클래스가 가지고 있는 멤버 변수가 하위 클래스로 상속되는 것처럼, 상위 클래스가 가지고 있는 메서드도 하위 클래스로 상속되어 하위 클래스에 사용할 수 있다.
(상속받은 메서드를 그대로 사용할 수도 있지만, 필요에 따라 메서드를 재정의하여 사용하는 경우가 있다.)
public class Parent {
public void overridingTest() {
System.out.println("부모 메서드의 내용");
}
}
public class Child extends Parent {
@Override
public void overridingTest() {
System.out.println("부모 클래스의 메서드를 상속받아 내용을 재정의해서 사용");
}
}
📖 다형성
다형성이란?
다형성이란 하나의 객체가 여러 가지 타입을 가질 수 있는 것을 의미한다. 자바에서는 이러한 다형성을 부모 클래스 타입의 참조 변수로 자식 클래스 타입의 인스턴스를 참조할 수 있도록 하여 구현하고 있다.
다형성을 활용하면 부모 클래스 타입의 참조 변수로 여러 자식 객체를 다룰 수 있으므로, 공통된 코드 한 번만 작성하면 다양한 객체에서 재사용이 가능해진다.
참조 변수의 다형성
자바에서는 다형성을 위해 부모 클래스 타입의 참조 변수로 자식 클래스 타입의 인스턴스를 참조할 수 있다.
이때, 참조 변수가 접근할 수 있는 멤버(필드 & 메서드)는 참조 변수의 타입(부모 클래스 기준)에 따라 결정된다.
따라서, 부모 클래스에 정의된 멤버만 사용할 수 있으며, 자식 클래스에서 추가된 멤버는 직접 접근할 수 없다.
단, 다운캐스팅을 하면 자식 클래스의 멤버도 접근할 수 있다.
class Parent {
void show() {
System.out.println("Parent 클래스");
}
}
class Child extends Parent {
void display() {
System.out.println("Child 클래스");
}
}
public class Main {
public static void main(String[] args) {
Parent p = new Child(); // 부모 타입으로 자식 객체 참조
p.show(); //가능 (Parent 클래스의 메서드)
p.display(); //불가능 (Child 클래스의 메서드는 접근 불가)
Child c = (Child) p; // 다운캐스팅
c.display(); //가능 (Child 클래스의 메서드 사용 가능)
}
}
📖 final 키워드
final
final 키워드는 변수, 메서드, 또는 클래스에 사용될 수 있다. final 키워드는 어떤 곳에 사용되냐에 따라 다른 의미를 가진다. 하지만 final 키워드를 붙이면 무언가를 제한한다는 의미를 가지는 것은 공통적인 성격이다.
변수
변수에 final을 붙이면 이 변수는 수정할 수 없다는 의미를 가진다. 수정될 수 없기 때문에 초기화 값은 필수적이다. 만약 객체안의 변수라면 생성자, static 블럭을 통한 초기화까지는 허용한다.
class Person {
String name;
public Person(String name) {
this.name = name;
}
}
public class Main {
public static void main(String[] args) {
final int number = 2; // Stack 영역에 저장되는 final 변수
final Person person = new Person("Alice"); // Heap 영역에 저장되는 객체
System.out.println(number); // 2 출력
System.out.println(person.name); // "Alice" 출력
// number = 3; // Error: final 변수는 변경할 수 없음
person.name = "Bob"; // 가능: 객체 내부 값은 변경 가능
System.out.println(person.name); // "Bob" 출력
// person = new Person("Charlie"); // Error: final 참조 변수는 다른 객체를 가리킬 수 없음!
}
}
메서드
메스드에 final을 붙이면 override를 제한하게 된다. 즉, 상속받은 클래스에서 해당 메서드를 수정해서 사용하지 못하도록 할 수 있는것이 메서드에 final을 붙이는 것이다.
class Person {
private final String name;
private final int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public void speech() {
System.out.println("나는 " + name + " 입니다.");
}
}
class Korean extends Person {
public Korean(String name, int age) {
super(name, age);
}
@Override
public void speech() {
System.out.println("나는 " + name + " 이며, " + age + " 입니다.");
}
}
클래스
클래스에 final 키우드를 붙이면 상속 불가능 클래스가 된다. 즉, 다른 클래스에서 상속하여 재정의를 할 수 없다. 대표적인 클래스로 Integer와 같은 Wrapper 클래스가 있다. 클래스 설계 시 재정의 여부를 생각해서 재정의를 불가능하게 사용하고 싶다면 final로 등록하는게 추후 유지보수 차원에서 권장된다.
'Study > CS 스터디 - Java' 카테고리의 다른 글
동시성 (0) | 2025.04.03 |
---|---|
Thread (0) | 2025.03.27 |
컬렉션 (0) | 2025.03.27 |
람다, 스트림, 어노테이션 (0) | 2025.03.20 |
문자열, 예외, 제네릭 (0) | 2025.03.19 |