Tổng hợp 40+ câu hỏi phỏng vấn Java phổ biến dành cho các lập trình viên Java, từ những câu hỏi cơ bản về cú pháp và tính năng ngôn ngữ đến những vấn đề nâng cao như xử lý đồng thời, tối ưu hóa hiệu suất và hiểu biết về các framework phổ biến.

Đọc bài viết này để hiểu thêm về:

  • Các câu hỏi phỏng vấn Java về Java Core Basics (Cơ bản về Java)
  • Các câu hỏi phỏng vấn Java liên quan đến lập trình hướng đối tượng trong Java
  • Các câu hỏi phỏng vấn Java liên quan đến các thư viện được sử dụng phổ biến trong Java
  • Các câu hỏi phỏng vấn Java về xử lý ngoại lệ(Exception Handling)
  • Các câu hỏi phỏng vấn Java về xử lý đa luồng
  • Các câu hỏi phỏng vấn Java liên quan đến Java Stream API và các tính năng nổi bật của Java 8

Java Developer là ai?

Java Developer đóng vai trò chủ chốt trong việc thiết kế, phát triển, kiểm thử và bảo trì các ứng dụng web, hệ thống doanh nghiệp và nền tảng di động, đảm bảo ứng dụng đáp ứng các yêu cầu kỹ thuật một cách hiệu quả.

Java Developer cần thành thạo ngôn ngữ Java, lập trình hướng đối tượng, và các công nghệ như Spring, Hibernate, cùng kiến thức về cơ sở dữ liệu SQL. Kỹ năng giải quyết vấn đề, làm việc nhóm, và khả năng tự học để cập nhật công nghệ mới là rất cần thiết, giúp Java Developer tối ưu hóa hiệu suất ứng dụng và đảm bảo chất lượng sản phẩm phần mềm.

Đọc thêm các bài viết sau để nắm vững tổng quan về Java và Java Developer:

Các câu hỏi phỏng vấn Java liên quan đến Java cơ bản

Liệt kê các kiểu dữ liệu trong Java

Trong Java, các loại dữ liệu được chia thành hai nhóm chính: Kiểu dữ liệu nguyên thủy (Primitive Data Type)Kiểu dữ liệu tham chiếu (Reference Data Type).

Kiểu dữ liệu nguyên thủy (Primitive Data Type)

Các kiểu dữ liệu này lưu trữ giá trị trực tiếp và được chia thành 8 loại cơ bản:

Loại  Miêu tả  Kích thước  Giá trị mặc định
Byte Số nguyên, từ -128 đến 127 1 byte 0
Short Số nguyên, từ -32,768 đến 32,767 2 byte 0
Int ố nguyên, từ -2^31 đến 2^31-1 4 byte 0
Long  Số nguyên, từ -2^63 đến 2^63-1 8 byte 0L
Float Số thực, độ chính xác đơn 4 byte  0.0f
Double  Số thực, độ chính xác kép 8 byte 0.0d
Char Ký tự Unicode 2 byte ‘\u0000’
Boolean Giá trị đúng hoặc sai 1 bit false

Kiểu dữ liệu tham chiếu (Reference Data Type)

Các kiểu dữ liệu này lưu trữ địa chỉ (tham chiếu) của đối tượng trong bộ nhớ. Chúng bao gồm:

Loại  Miêu tả
String Chuỗi ký tự
Array Mảng các phần tử cùng kiểu
List Định nghĩa các đối tượng
Interface Định nghĩa hành vi cho các lớp
Enum  Kiểu liệt kê, đại diện cho tập các hằng số

Kiểu dữ liệu nguyên thủy là các loại dữ liệu cơ bản và nhanh, trong khi kiểu dữ liệu tham chiếu được dùng để làm việc với các đối tượng phức tạp.

Tính năng nổi bật của Java so với các ngôn ngữ khác?

Java nổi bật với nhiều tính năng khiến nó trở thành một trong những ngôn ngữ lập trình phổ biến nhất, đặc biệt là trong các ứng dụng doanh nghiệp và hệ thống lớn. Một số tính năng chính bao gồm:

  • Nền tảng độc lập: Java chạy trên nền tảng Java Virtual Machine (JVM), cho phép mã Java có thể chạy trên nhiều hệ điều hành khác nhau mà không cần phải thay đổi. Điều này giúp Java có khả năng “viết một lần, chạy mọi nơi” (Write Once, Run Anywhere).
  • Quản lý bộ nhớ tự động: Java tích hợp cơ chế garbage collection, giúp giải phóng bộ nhớ tự động khi các đối tượng không còn được sử dụng. Điều này giảm thiểu nguy cơ rò rỉ bộ nhớ và giúp tối ưu hóa hiệu suất ứng dụng.
  • Bảo mật cao: Java có các tính năng bảo mật như kiểm soát truy cập, mã hóa và sandboxing giúp bảo vệ dữ liệu và hệ thống, đặc biệt quan trọng khi chạy các ứng dụng trong môi trường mạng.
  • Hướng đối tượng: Java là một ngôn ngữ hoàn toàn hướng đối tượng, giúp mã nguồn dễ bảo trì, mở rộng và tái sử dụng. Điều này rất có lợi cho việc phát triển các dự án lớn.
  • Đa luồng (Multithreading): Java hỗ trợ lập trình đa luồng, cho phép thực thi nhiều tác vụ đồng thời, giúp cải thiện hiệu suất và khả năng đáp ứng của ứng dụng.
  • Thư viện phong phú và cộng đồng mạnh mẽ: Java có thư viện chuẩn phong phú và cộng đồng lập trình viên rộng lớn, giúp việc phát triển và xử lý các vấn đề trở nên thuận tiện hơn.

Những tính năng này giúp Java đáp ứng tốt các yêu cầu từ các hệ thống phức tạp, đòi hỏi hiệu năng, bảo mật và khả năng mở rộng cao.

Các công cụ phát triển như JVM, JRE, và JDK khác nhau như thế nào?

Thành phần  Mô tả Mục đích 
JVM (Java Virtual Machine) Java Virtual Machine là máy ảo chạy mã bytecode của Java. Nó thực hiện quá trình biên dịch và tối ưu hóa mã thành mã máy để chương trình Java có thể chạy được. Giúp ứng dụng Java chạy trên mọi nền tảng hỗ trợ JVM mà không cần thay đổi mã nguồn (Write Once, Run Anywhere).
JRE (Java Runtime Environment) Java Runtime Environment là môi trường để chạy các ứng dụng Java. Nó bao gồm JVM và các thư viện lớp Java cần thiết. Cho phép người dùng chạy các ứng dụng Java đã được biên dịch mà không cần JDK.
JDK (Java Development Kit) Java Development Kit là bộ công cụ phát triển phần mềm Java. Nó bao gồm JRE, JVM, cùng các công cụ phát triển như trình biên dịch javac, trình gỡ lỗi. Cung cấp môi trường để lập trình viên phát triển, biên dịch và kiểm thử các ứng dụng Java.

JDK là cần thiết cho việc phát triển ứng dụng, trong khi JRE và JVM chủ yếu để chạy ứng dụng đã biên dịch.

Các loại biến trong Java là gì?

Trong Java, có bốn loại biến chính:

  1. Biến cục bộ (Local Variables): Khai báo trong một phương thức, constructor, hoặc khối mã, và chỉ có thể truy cập trong phạm vi của nó. Biến cục bộ không có giá trị mặc định và phải được khởi tạo trước khi sử dụng.
  2. Biến thành viên (Instance Variables): Khai báo trong một lớp nhưng ngoài bất kỳ phương thức nào. Mỗi đối tượng của lớp sẽ có bản sao riêng của biến này.
  3. Biến tĩnh (Static Variables): Khai báo với từ khóa static trong một lớp. Đây là biến dùng chung cho tất cả các đối tượng của lớp và được chia sẻ trên lớp thay vì đối tượng.
  4. Biến hằng (Constants): Sử dụng từ khóa final để khai báo, làm cho giá trị của biến không thể thay đổi sau khi khởi tạo.

Trong Java, có một số từ khóa quan trọng thường gặp:

  • final: Dùng để khai báo hằng số hoặc phương thức không thể ghi đè, và lớp không thể kế thừa.
  • static: Biến hoặc phương thức được khai báo là static sẽ tồn tại ở cấp lớp, nghĩa là chúng không phụ thuộc vào đối tượng và có thể được truy cập trực tiếp từ lớp.
  • volatile: Đảm bảo rằng giá trị của biến sẽ được đọc từ bộ nhớ chính mỗi khi có truy cập, phù hợp cho các biến được truy cập bởi nhiều luồng trong lập trình đa luồng.
  • synchronized: Được sử dụng để đồng bộ hóa các phương thức hoặc khối mã, giúp ngăn chặn việc truy cập cùng lúc vào tài nguyên dùng chung từ nhiều luồng.
  • abstract: Được dùng cho lớp hoặc phương thức trừu tượng. Một lớp trừu tượng không thể được khởi tạo và thường được kế thừa.
  • transient: Dùng để bỏ qua không tuần tự hóa một biến khi lớp được tuần tự hóa.
  • this: Đại diện cho đối tượng hiện tại trong phương thức hoặc constructor.
  • super: Dùng để tham chiếu đến lớp cha hoặc phương thức của lớp cha trong lớp con.
  • enum: Được sử dụng để định nghĩa các hằng số dạng liệt kê.

Phân biệt giữa kiểu dữ liệu nguyên thủy (primitive) và kiểu đối tượng (object/reference) trong Java?

Loại  Kiểu dữ liệu nguyên thuỷ Kiểu đối tượng
Định nghĩa  Các kiểu dữ liệu đơn giản có sẵn trong Java (int, float, double, char, boolean, v.v.) Các kiểu dữ liệu do người dùng định nghĩa (String, Array, các class tùy chỉnh)
Bộ nhớ lưu trữ Lưu trữ trực tiếp trong bộ nhớ stack Lưu trữ trong bộ nhớ heap, biến tham chiếu chỉ lưu địa chỉ đối tượng
Tính bất biến Các giá trị là bất biến (immutable), không thể chứa phương thức Các đối tượng có thể thay đổi (mutable) và chứa các phương thức, biến
Truy cập và hiệu suất  Truy cập nhanh hơn, không cần cấp phát bộ nhớ đặc biệt Truy cập chậm hơn vì tham chiếu đối tượng và cần cấp phát bộ nhớ trên heap
Chứa phương thức Không thể chứa phương thức Có thể chứa các phương thức và biến khác nhau

Khái niệm Overloading và Overriding là gì? Nêu ví dụ minh họa.

Trong Java, OverloadingOverriding là hai cơ chế quan trọng của tính đa hình (polymorphism), nhưng chúng có những điểm khác nhau rõ rệt về cách hoạt động và mục đích sử dụng.

  • Overloading (Nạp chồng phương thức): Đây là khi chúng ta định nghĩa nhiều phương thức cùng tên trong cùng một lớp nhưng có tham số khác nhau (về số lượng hoặc kiểu dữ liệu tham số). Mục tiêu của overloading là cung cấp các phiên bản khác nhau của cùng một phương thức để phù hợp với các kiểu dữ liệu hoặc số lượng tham số khác nhau. Điều này xảy ra ở compile-time.

Ví dụ:

public class MathOperation {
  public int add(int a, int b) {
    return a + b;
  }

  public double add(double a, double b) {
    return a + b;
  }
}

Trong ví dụ này, phương thức add được nạp chồng (overloaded) để xử lý cả kiểu intdouble.

  • Overriding (Ghi đè phương thức): Overriding xảy ra khi một lớp con định nghĩa lại phương thức đã được khai báo trong lớp cha. Phương thức được ghi đè phải có cùng tên, kiểu trả về và danh sách tham số như phương thức trong lớp cha. Mục tiêu của overriding là để lớp con có thể cung cấp phiên bản phương thức phù hợp với đặc điểm của lớp đó. Điều này xảy ra ở runtime.

Ví dụ:

class Animal {
  public void sound() {
    System.out.println("Some sound");
  }
}

class Dog extends Animal {
  @Override
  public void sound() {
    System.out.println("Bark");
  }
}

Trong ví dụ này, lớp Dog ghi đè (override) phương thức sound từ lớp Animal để cung cấp hành vi riêng (tiếng sủa) của nó.

Constructor là gì? Khác gì với phương thức (method) thông thường?

Constructor là một phương thức đặc biệt trong Java được sử dụng để khởi tạo đối tượng của lớp. Điểm quan trọng là constructor có cùng tên với lớp và không có kiểu trả về (không có void hoặc kiểu dữ liệu). Nó được gọi tự động khi một đối tượng được tạo ra từ lớp.

Khác với constructor, phương thức (method) trong Java là các hàm có thể trả về giá trị (hoặc không) và có thể có tên khác với tên của lớp. Một phương thức có thể được gọi bất kỳ lúc nào khi đối tượng đã được khởi tạo.

Ví dụ về constructor:

class Car {
  String model;
  int year;

  // Constructor
  public Car(String model, int year) {
    this.model = model;
    this. year = year;
  }
}

Ví dụ về method:

class Car {
  String model;
  int year;

  // Method
  public void startEngine() {
    System.out.println("Engine started");
  }
}

Static keyword có vai trò gì? Static block và static method khác nhau như thế nào?

Trong Java, từ khóa static được sử dụng để khai báo các thành phần của lớp mà không cần phải tạo đối tượng của lớp đó. Nó có thể áp dụng cho các biến, phương thức, khối mã (block) và lớp nội bộ (nested class).

  • Static variable (biến static): Là biến được chia sẻ giữa tất cả các đối tượng của lớp. Nó không bị thay đổi khi một đối tượng mới được tạo, mà thay vào đó, tất cả các đối tượng chia sẻ giá trị của nó.
  • Static method (phương thức static): Là phương thức có thể được gọi mà không cần tạo đối tượng của lớp. Nó có thể truy cập và thay đổi các biến static, nhưng không thể truy cập các biến hoặc phương thức non-static của lớp.
  • Static block (khối mã static): Là một khối mã đặc biệt được thực thi một lần duy nhất khi lớp được nạp vào bộ nhớ, trước khi bất kỳ phương thức hoặc đối tượng nào của lớp đó được tạo ra. Nó thường được sử dụng để khởi tạo các biến static hoặc thực hiện một số công việc khởi tạo mà chỉ cần làm một lần.

Sự khác nhau giữa Static block và Static method:

  • Static block được sử dụng để thực hiện khởi tạo hoặc các thao tác cấu hình tĩnh khi lớp được nạp vào bộ nhớ. Khối mã này chỉ chạy một lần duy nhất, ngay khi lớp được nạp.
  • Static method là phương thức có thể được gọi mà không cần tạo đối tượng của lớp và thường được sử dụng để thực hiện các tác vụ chung không phụ thuộc vào trạng thái của đối tượng. Phương thức này có thể được gọi từ bất kỳ đâu trong chương trình mà không cần sự hiện diện của đối tượng.
Tiêu chí  Static Block  Static Method
Khi nào được thực thi Được thực thi ngay khi lớp được nạp vào bộ nhớ Chỉ được thực thi khi được gọi trực tiếp
Mục đích chính Khởi tạo các biến tĩnh hoặc thực hiện các thiết lập trước khi chương trình bắt đầu Thực hiện các tác vụ có thể được gọi lại nhiều lần trong suốt chương trình
Cách khai báo Dùng khối static { … } Định nghĩa bằng từ khóa static trong khai báo hàm
Tham số  không có tham số Có thể có tham số đầu vào
Khả năng sử dụng lại Không thể sử dụng lại (vì chỉ chạy một lần) Co thể tái dụng và gọi hàm lại bất kỳ lúc nào
Truy cập tới thành phần Chỉ có thể truy cập đến các thành phần tĩnh của lớp Có thể truy cập đến các thành phần tĩnh của lớp

Ví dụ minh họa:

class MyClass {
  static int count = 0; // Static variable

  static {
    // Static block: chạy khi lớp được nạp vào bộ nhớ
    count = 10;
    System.out.println("Static block executed!");
  }

  static void displayCount() { // Static method
    System.out.println("Count: " + count);
  }

  public static void main(String[] args) {
    MyClass.displayCount(); // Gọi phương thức static
  }
}

Các câu hỏi phỏng vấn Java liên quan đến OOP

Đọc thêm:

Giải thích 4 nguyên lý chính của lập trình hướng đối tượng (Encapsulation, Inheritance, Polymorphism, Abstraction) trong Java

Lập trình hướng đối tượng (OOP) là một mô hình lập trình dựa trên việc tổ chức mã nguồn thành các đối tượng và lớp. Bốn nguyên lý chính của OOP trong Java là Encapsulation (Đóng gói), Inheritance (Kế thừa), Polymorphism (Đa hình)Abstraction (Trừu tượng). Dưới đây là giải thích chi tiết về từng nguyên lý:

Encapsulation (Đóng gói)

Encapsulation là quá trình nhóm các thuộc tính (biến) và phương thức (hàm) lại thành một đơn vị duy nhất, gọi là lớp. Điều này giúp bảo vệ dữ liệu bên trong lớp khỏi bị thay đổi trực tiếp từ bên ngoài. Các thuộc tính thường được khai báo là private và chỉ có thể truy cập thông qua các phương thức getter và setter công khai.

Ví dụ:

public class Person {
  private String name; // private thuộc tính

  public String getName() { // phương thức getter
    return name;
  }

  public void setName(String name) { // phương thức setter
    this.name = name;
  }
}

Inheritance (Kế thừa)

Inheritance cho phép một lớp (subclass) kế thừa các thuộc tính và phương thức của một lớp khác (superclass). Điều này giúp tái sử dụng mã nguồn và tạo ra các lớp con có thể mở rộng các tính năng của lớp cha mà không cần viết lại mã.

Ví dụ:

class Animal {
  public void makeSound() {
    System.out.println("Animal makes a sound");
  }
}

class Dog extends Animal {
  @Override
  public void makeSound() {
    System.out.println("Dog barks");
  }
}

Polymorphism (Đa hình)

Polymorphism cho phép các đối tượng của các lớp khác nhau có thể sử dụng các phương thức giống nhau, nhưng cách thức thực hiện lại khác nhau. Điều này giúp code linh hoạt và dễ bảo trì hơn. Có hai loại polymorphism trong Java: Method Overloading (nạp chồng phương thức) và Method Overriding (ghi đè phương thức).

Ví dụ: 

Method Overloading:

class Calculator {
  public int add(int a, int b) {
    return a + b;
  }
  public double add(double a, double b) {
    return a + b;
  }
}

Method Overriding:

class Animal {
  public void makeSound() {
    System.out.println("Animal makes a sound");
  }
}

class Dog extends Animal {
  @Override
  public void makeSound() {
    System.out.println("Dog barks");
  }
}

Abstraction (Trừu tượng)

Abstraction là quá trình ẩn đi các chi tiết triển khai và chỉ cung cấp những gì cần thiết cho người sử dụng. Trong Java, abstraction có thể thực hiện thông qua abstract classesinterfaces. Lớp abstract không thể khởi tạo đối tượng trực tiếp và thường chứa các phương thức abstract mà các lớp con phải cài đặt.

Ví dụ:

abstract class Animal {
  abstract void makeSound(); // phương thức abstract
}

class Dog extends Animal {
  @Override
  void makeSound() {
    System.out.println("Dog barks");
  }
}

Lập trình hướng đối tượng được xem là kiến thức cực kỳ quan trọng cho các lập trình viên nói chung và lập trình Java nói riêng, bạn có tìm hiểu thêm chi tiết tại: OOP là gì? 4 đặc tính cơ bản của OOP

Interface là gì? So sánh Interface và Abstract Class

  • Interface là một kiểu dữ liệu đặc biệt trong Java, nó định nghĩa các phương thức mà các lớp thực thi phải cài đặt, nhưng không chứa bất kỳ hiện thực nào của phương thức. Các phương thức trong interface mặc định là public abstract. Interface chỉ có thể chứa khai báo phương thức và các hằng số (constants).
  • Abstract Class là một lớp không thể được khởi tạo, nhưng có thể chứa cả phương thức đã triển khai (concrete methods) và phương thức chưa triển khai (abstract methods). Abstract class dùng để định nghĩa một mẫu cho các lớp con.
Tiêu chí Interface Abstract Class
Phương thức Chỉ chứa phương thức abstract (không có phần thân) Có thể chứa cả phương thức abstract và phương thức cụ thể (concrete methods)
Kế thừa Lớp có thể implement nhiều interface Lớp chỉ có thể kế thừa 1 abstract class
Trường hợp sử dụng Dùng khi các lớp không có mối quan hệ cha con rõ ràng, nhưng cần chia sẻ các phương thức chung Dùng khi các lớp có mối quan hệ cha con và cần kế thừa tính năng chung
Khởi tạo đối tượng Không thể khởi tạo đối tượng từ interface Không thể khởi tạo đối tượng từ abstract class, nhưng có thể chứa constructor
Các thành phần Chỉ có thể chứa hằng số (constant), không có biến instance Có thể chứa biến instance và hằng số
Tính kế thừa Tất cả phương thức mặc định là public Có thể có phương thức với các mức độ truy cập khác nhau (public, private, protected)
Mục đích Tạo ra các lớp không có quan hệ giữa chúng, nhưng có chung các phương thức Định nghĩa một lớp cơ sở chung cho các lớp con với một số phần triển khai sẵn

Ví dụ:

// Interface
public interface Animal {
  void sound(); // Phương thức abstract
}

// Abstract Class
public abstract class Animal {
  abstract void sound(); // Phương thức abstract

  void eat() { // Phương thức cụ thể
    System.out.println("Eating...");
  }
}

Như vậy, Interface thường được sử dụng khi cần định nghĩa một nhóm các phương thức mà nhiều lớp không liên quan có thể thực thi, còn Abstract Class được sử dụng khi cần kế thừa các tính năng chung từ một lớp cơ sở trong hệ thống kế thừa.

Tính đóng gói (Encapsulation) và vai trò của từ khóa private trong Java?

Tính đóng gói (Encapsulation) là một trong những nguyên tắc quan trọng của lập trình hướng đối tượng (OOP), giúp bảo vệ dữ liệu khỏi bị thay đổi trực tiếp từ bên ngoài lớp. Thay vào đó, các thuộc tính và phương thức được ẩn giấu và chỉ có thể truy cập thông qua các phương thức công khai (getter, setter) nếu cần thiết. Điều này giúp đảm bảo tính toàn vẹn của dữ liệu và cung cấp các cơ chế kiểm tra và xử lý khi dữ liệu được thay đổi.

Từ khóa private trong Java đóng vai trò rất quan trọng trong việc thực hiện tính đóng gói. Khi khai báo một thuộc tính hoặc phương thức là private, điều đó có nghĩa là chúng chỉ có thể được truy cập và sử dụng trong chính lớp đó, không thể truy cập trực tiếp từ bên ngoài. Điều này giúp ngăn chặn việc truy cập trái phép và bảo vệ dữ liệu khỏi những thay đổi không mong muốn.

Ví dụ:

public class Person {
  private String name; // thuộc tính private
  private int age;

  // Constructor
  public Person (String name, int age) {
    this.name = name;
    this.age = age;
  }

  // Getter và Setter để truy cập thuộc tính
  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }
}

Trong ví dụ trên, thuộc tính nameageprivate, vì vậy chúng không thể được truy cập trực tiếp từ bên ngoài lớp Person. Thay vào đó, chúng chỉ có thể được truy cập thông qua các phương thức getter và setter. Điều này giúp đảm bảo rằng các giá trị của nameage luôn hợp lệ và có thể kiểm tra trong quá trình thay đổi.

Làm thế nào để ngăn chặn việc kế thừa trong Java? Nêu ví dụ sử dụng từ khóa final

Để ngăn chặn việc kế thừa trong Java, bạn có thể sử dụng từ khóa final với lớp hoặc phương thức. Khi một lớp được khai báo là final, nó không thể bị kế thừa. Tương tự, khi một phương thức được khai báo là final, phương thức đó không thể bị ghi đè (override) trong lớp con.

Ví dụ:

// Lớp final không thể bị kế thừa
public final class MyClass {
    public void display() {
        System.out.println("Hello from MyClass");
    }
}

// Lớp con không thể kế thừa từ MyClass vì MyClass là final
// class SubClass extends MyClass { // Lỗi biên dịch: Cannot inherit from final 'MyClass' }

Trong ví dụ trên, lớp MyClass được khai báo là final, do đó không thể bị kế thừa trong lớp SubClass.

Ví dụ về phương thức final:

class Parent {
    public final void show() {
        System.out.println("This is a final method");
    }
}

class Child extends Parent {
    // Không thể ghi đè phương thức show() vì nó là final
    // public void show() { } // Lỗi biên dịch: Cannot override the final method from Parent
}

Ở đây, phương thức show() trong lớp Parent được khai báo là final, do đó lớp con Child không thể ghi đè (override) phương thức này.

Làm sao để tạo một lớp bất biến (immutable class) trong Java?

Để tạo một lớp bất biến (immutable class) trong Java, bạn cần tuân thủ các bước sau:

Bước 1. Đánh dấu lớp là final:

Lớp phải được khai báo là final để không thể bị kế thừa và thay đổi.

public final class Person {

Bước 2. Đánh dấu các trường (fields) là privatefinal:

Đảm bảo rằng các trường của lớp không thể thay đổi sau khi được khởi tạo.

private final String name;

private final int age;

Bước 3. Khởi tạo giá trị của các trường qua constructor:

Tạo một constructor để khởi tạo các trường khi đối tượng được tạo ra. Constructor không nên cho phép thay đổi giá trị của các trường sau khi đối tượng đã được khởi tạo.

public Person (String name, int age) {
  this.name = name;
  this.age = age;
}

Bước 4. Không cung cấp setter methods:

Để đảm bảo tính bất biến, không được cung cấp phương thức setter, vì chúng sẽ thay đổi giá trị của các trường.

Bước 5. Nếu có các trường tham chiếu đối tượng (reference types), hãy tạo bản sao của chúng trong constructor và getter:

Nếu trường là một đối tượng (ví dụ List hoặc Date), bạn cần đảm bảo rằng không thể thay đổi trạng thái của đối tượng thông qua lớp bất biến. Hãy tạo bản sao của đối tượng trong cả constructor và getter.

private final Date birthDate;

public Person (String name, int age, Date birthDate) {
  this.name = name;
  this.age = age;
  this.birthDate = new Date(birthDate.getTime()); // Tạo bản sao
}

public Date getBirthDate() {
  return new Date(birthDate.getTime()); // Trả về bản sao
}

Tóm lại: Để tạo một lớp bất biến trong Java, bạn cần đảm bảo rằng lớp là final, các trường là privatefinal, chỉ có getter và không có setter, và xử lý đúng các trường tham chiếu để bảo vệ tính bất biến của chúng.

Phân biệt this và super keyword trong Java

Trong Java, cả thissuper đều là các từ khóa tham chiếu đến đối tượng, nhưng chúng có mục đích và phạm vi sử dụng khác nhau:

this

Tham chiếu đến đối tượng hiện tại, tức là đối tượng của lớp hiện tại (lớp mà phương thức hoặc biến này được gọi). Nó thường được sử dụng trong các tình huống:

  • Để phân biệt giữa biến instance và tham số của phương thức hoặc constructor khi chúng có tên giống nhau.
  • Để gọi constructor khác của cùng một lớp từ một constructor khác.

Ví dụ:

class MyClass {
  int x;

  MyClass(int x) {
    this.x = x; // 'this' phân biệt biến instance 'x' và tham số 'x'
  }
}

super

Tham chiếu đến lớp cha (superclass) của lớp hiện tại. Nó được sử dụng trong các tình huống:

  • Để gọi constructor của lớp cha.
  • Để truy cập đến các phương thức hoặc biến của lớp cha bị che khuất bởi lớp con.

Ví dụ:

class Parent {
  void display() {
    System.out.println("Display of Parent");
  }
}

class Child extends Parent {
  void display() {
    super.display(); // Gọi phương thức display() của lớp cha
    System.out.println("Display of Child");
  }
}

Cả thissuper đều đóng vai trò quan trọng trong việc quản lý đối tượng và kế thừa trong Java, giúp bạn viết mã rõ ràng và hiệu quả hơn trong các mối quan hệ giữa các lớp.

Anonymous class là gì? Khi nào nên sử dụng?

Anonymous class trong Java là một lớp không có tên và được định nghĩa ngay tại chỗ, thường là khi bạn cần một lớp để thực thi một interface hoặc kế thừa một lớp. Nó giúp tiết kiệm không gian mã và thường được sử dụng trong những tình huống cần một triển khai tạm thời mà không muốn tạo ra một lớp con riêng biệt.

Chúng ta thường sử dụng anonymous class khi:

  • Cần một lớp chỉ được sử dụng một lần trong phạm vi của một phương thức hoặc block mã.
  • Muốn tạo các instance của lớp mà không cần phải khai báo lớp đó trước.
  • Ví dụ điển hình là sử dụng trong callback, event handling, hoặc trong các API như Java Swing hay các thư viện xử lý sự kiện.

Tuy nhiên, cần tránh sử dụng anonymous class khi logic của lớp quá phức tạp hoặc khi cần tái sử dụng lớp nhiều lần, vì điều này có thể gây khó khăn trong việc duy trì mã nguồn.

Java Collections Framework (các thư viện sử dụng phổ biến trong Java)

Collections Framework là gì? Kể tên các interface chính của Java Collections Framework

Collections Framework trong Java là một tập hợp các lớp và interface giúp quản lý nhóm đối tượng, bao gồm các thao tác như lưu trữ, truy xuất và sắp xếp dữ liệu. Nó cung cấp các cấu trúc dữ liệu như List, Set, và Map, giúp lập trình viên lựa chọn và sử dụng cấu trúc phù hợp với yêu cầu của ứng dụng.

Ba interface chính trong Collections Framework là:

  1. List: Là một tập hợp các phần tử có thứ tự và có thể chứa các phần tử trùng lặp. Các lớp triển khai phổ biến là ArrayListLinkedList. List cho phép truy cập phần tử theo chỉ số và duy trì thứ tự thêm vào.
  2. Set: Là một tập hợp các phần tử không có thứ tự và không chứa phần tử trùng lặp. Các lớp triển khai phổ biến là HashSet, LinkedHashSetTreeSet. Set không cho phép các phần tử trùng lặp, và thứ tự của các phần tử không được đảm bảo (trừ khi sử dụng LinkedHashSet hoặc TreeSet).
  3. Map: Là một tập hợp các cặp khóa-giá trị, trong đó mỗi khóa là duy nhất và liên kết với một giá trị. Các lớp triển khai phổ biến là HashMap, LinkedHashMap, và TreeMap. Map cho phép truy xuất giá trị thông qua khóa.

Ví dụ trong ứng dụng:

  • Nếu cần duy trì một danh sách các phần tử theo thứ tự và có thể có trùng lặp, chúng ta sẽ sử dụng List (ví dụ: ArrayList).
  • Nếu không cần trùng lặp và không quan tâm đến thứ tự, Set sẽ là lựa chọn phù hợp (ví dụ: HashSet).
  • Khi cần lưu trữ dữ liệu dưới dạng các cặp khóa-giá trị, Map sẽ là giải pháp tốt nhất (ví dụ: HashMap).

So sánh Array và ArrayList trong Java

  • Array là cấu trúc dữ liệu cơ bản, phù hợp khi bạn cần một mảng với kích thước cố định và không cần thay đổi kích thước trong quá trình sử dụng.
  • ArrayList là một lớp trong Java Collection Framework, cung cấp tính linh hoạt hơn trong việc thay đổi kích thước và thao tác với các phần tử, nhưng có chi phí bộ nhớ và hiệu suất cao hơn so với mảng truyền thống.
Tiêu chí  Array  ArrayList
Kích thước Kích thước cố định, không thể thay đổi sau khi khởi tạo. Kích thước thay đổi động khi thêm/bớt phần tử.
Loại dữ liệu Có thể chứa các phần tử của kiểu dữ liệu nguyên thủy và đối tượng. Chỉ chứa các phần tử của kiểu đối tượng (Object).
Hiệu suất Nhanh hơn trong truy cập phần tử do kích thước cố định và lưu trữ liên tiếp. Thấp hơn vì phải thay đổi kích thước và tạo lại mảng khi cần.
Dễ sử dụng Ít dễ sử dụng hơn do không có các phương thức bổ sung để thao tác. Dễ sử dụng hơn nhờ các phương thức như add(), remove(), size().
Tính linh hoạt Ít linh hoạt, không thể thay đổi kích thước sau khi khởi tạo. Linh hoạt, có thể thay đổi kích thước tự động khi phần tử được thêm vào hoặc xóa.
Sử dụng bộ nhớ Dùng bộ nhớ hiệu quả hơn vì không có sự bổ sung chi phí cho cấu trúc dữ liệu. Tốn bộ nhớ hơn do phải lưu trữ cả thông tin về kích thước và các đối tượng.
Khả năng chứa phần tử Có thể chứa các phần tử với cùng kiểu dữ liệu (kiểu nguyên thủy hoặc đối tượng). Chỉ chứa các đối tượng, không thể chứa các kiểu dữ liệu nguyên thủy trực tiếp.
Thao tác thêm/xóa Cần phải tạo mảng mới để thay đổi kích thước. Cung cấp các phương thức add(), remove() để thêm/xóa phần tử dễ dàng.

Phân biệt giữa ArrayList và LinkedList?

  • ArrayList: Là một lớp triển khai của List sử dụng mảng động để lưu trữ các phần tử. Nó cung cấp truy cập nhanh đến các phần tử thông qua chỉ mục (index) nhưng hiệu suất kém khi thêm hoặc xóa phần tử ở giữa danh sách vì phải dịch chuyển các phần tử.
  • LinkedList: Là một lớp triển khai của List sử dụng danh sách liên kết (linked list) để lưu trữ các phần tử. Nó cho phép thêm và xóa phần tử nhanh chóng ở đầu và cuối danh sách, nhưng truy cập các phần tử theo chỉ mục chậm hơn vì phải duyệt qua các phần tử trước đó.

Bảng so sánh ArrayList vs LinkedList:

Tiêu chí  ArrayList LinkedList
Cấu trúc dữ liệu Dựa trên mảng (array) Dựa trên danh sách liên kết đôi (doubly linked list)
Truy cập phần tử Nhanh (O(1) cho truy cập trực tiếp) Chậm (O(n) vì phải duyệt từ đầu danh sách)
Thêm/Xóa phần tử giữa danh sách Chậm (O(n) do cần dịch chuyển các phần tử khác) Nhanh (O(1) khi có con trỏ đến vị trí cần thêm/xóa)
Thêm/Xóa phần tử cuối danh sách Nhanh (O(1) nếu chưa đầy, O(n) khi cần mở rộng kích thước mảng) Nhanh (O(1) nhờ sử dụng con trỏ cuối)
Thêm/Xóa phần tử đầu danh sách Chậm (O(n) do cần dịch chuyển các phần tử khác) Nhanh (O(1) nhờ con trỏ đầu)
Bộ nhớ Sử dụng bộ nhớ liên tục (mảng có kích thước cố định hoặc phải tăng khi đầy) Sử dụng bộ nhớ phân mảnh (cần thêm bộ nhớ cho con trỏ)
Trường hợp sử dụng phù hợp Khi truy cập phần tử theo chỉ số thường xuyên Khi thêm/xóa phần tử ở vị trí bất kỳ thường xuyên
Yêu cầu bộ nhớ Bộ nhớ ít hơn do không cần lưu thông tin về liên kết Bộ nhớ nhiều hơn do cần lưu trữ thông tin liên kết giữa các phần tử
Duyệt danh sách Có thể dễ dàng duyệt ngược/lại bằng chỉ số Khó duyệt ngược/lại nếu không có con trỏ trước đó

HashMap và Hashtable khác nhau ở điểm nào?

Tiêu chí  HashMap Hashtable
Phiên bản Java 1.2 (thuộc Collections Framework) Java 1.0 (thuộc phần lõi của Java)
Thread-Safe Không synchronized (không thread-safe) Synchronized (thread-safe)
Hiệu suất Nhanh hơn vì không có synchronized Chậm hơn do synchronized
Cho phép null Cho phép một khóa null và nhiều giá trị null Không cho phép khóa hoặc giá trị null
Sử dụng Thường dùng khi không cần thread-safety Dùng khi cần thread-safety

Các phương pháp để duyệt qua một List trong Java? (for-each, Iterator, Stream)

Có ba phương pháp phổ biến để duyệt qua một List trong Java:

Dùng vòng lặp for-each

Đây là cách đơn giản và dễ hiểu nhất. Phương pháp này giúp bạn duyệt qua từng phần tử trong List mà không cần quan tâm đến chỉ mục.

List<String> list = Arrays.asList("A", "B", "C");
for (String item : list) {
  System.out.println(item);
}

Dùng Iterator

Iterator cung cấp một cách tiếp cận linh hoạt hơn, đặc biệt khi bạn cần loại bỏ các phần tử trong quá trình duyệt qua List. Iterator cũng hỗ trợ duyệt qua các Collection mà không cần quan tâm đến loại của Collection đó.

List<String> list = Arrays.asList("A", "B", "C");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
  System.out.println(iterator.next());
}

Dùng Stream (Java 8 trở lên)

Phương pháp này sử dụng Stream API, cho phép bạn áp dụng các phép toán hàm (functional) như filter, map, và forEach, giúp mã nguồn gọn gàng và dễ hiểu hơn.

List<String> list = Arrays.asList("A", "B", "C");
list.forEach (System.out::println);

Giải thích thêm:

  • for-each là phương pháp đơn giản nhất nhưng không hỗ trợ các phép toán phức tạp như lọc hay chuyển đổi dữ liệu.
  • Iterator cho phép bạn điều khiển quá trình duyệt linh hoạt hơn, nhưng cú pháp có phần phức tạp hơn.
  • Stream là lựa chọn tốt nếu bạn đang làm việc với Java 8 trở lên và muốn tận dụng các khả năng xử lý dữ liệu hàm như lọc, nhóm, hoặc tính toán trên các phần tử của List.

TreeSet là gì? Làm thế nào để sắp xếp một TreeSet?

TreeSet là một lớp trong Java, thuộc package java.util, triển khai giao diện Set. Nó lưu trữ các phần tử theo thứ tự tự nhiên (natural order) hoặc theo một Comparator nếu được cung cấp. Một đặc điểm quan trọng của TreeSet là không cho phép các phần tử trùng lặp và tự động duy trì thứ tự sắp xếp khi các phần tử được thêm vào.

Cách sắp xếp một TreeSet: Mặc định, TreeSet sắp xếp các phần tử theo thứ tự tự nhiên của chúng (đối với các phần tử implement Comparable). Nếu bạn muốn sắp xếp theo một tiêu chí khác, bạn có thể cung cấp một đối tượng Comparator khi khởi tạo TreeSet.

Ví dụ:

// Sắp xếp theo thứ tự tự nhiên
TreeSet<Integer> set = new TreeSet<>();
set.add(10);
set.add(5);
set.add(20);

// Sắp xếp theo một Comparator (theo thứ tự giảm dần)
TreeSet<Integer> descendingSet = new
descendingSet.add(10);
descendingSet.add(5);
descendingSet.add(20);
TreeSet>(Comparator.reverseOrder());

Như vậy, TreeSet đảm bảo rằng các phần tử luôn được sắp xếp theo một quy tắc nhất định (tự nhiên hoặc theo Comparator), và không cho phép phần tử trùng lặp.

Comparator và Comparable khác nhau như thế nào? Khi nào dùng cái nào?

Comparable là một interface được sử dụng để so sánh các đối tượng trong cùng một lớp. Khi một lớp implement Comparable, các đối tượng của lớp đó có thể được sắp xếp tự động bằng cách sử dụng các phương thức như Collections.sort(). Lớp này cần phải implement phương thức compareTo(T o), giúp so sánh đối tượng hiện tại với đối tượng khác.

Comparator, ngược lại, là một interface được sử dụng để so sánh các đối tượng của nhiều lớp khác nhau hoặc các đối tượng mà không thể thay đổi mã nguồn của lớp. Khi sử dụng Comparator, bạn cần implement phương thức compare(T o1, T o2) để chỉ rõ cách thức so sánh hai đối tượng.

Khi nào sử dụng:

  • Dùng Comparable khi bạn muốn định nghĩa một thứ tự mặc định cho đối tượng trong một lớp.
  • Dùng Comparator khi bạn cần định nghĩa các cách so sánh khác nhau hoặc khi bạn không thể thay đổi mã nguồn của lớp.

Các câu hỏi phỏng vấn Java liên quan đến Exception Handling (Xử lý ngoại lệ)

Ngoại lệ (Exception) trong Java là gì? Phân biệt giữa Checked và Unchecked Exception.

Ngoại lệ (Exception) trong Java là một sự kiện không mong đợi xảy ra trong quá trình thực thi chương trình, gây gián đoạn luồng điều khiển bình thường của ứng dụng. Khi một ngoại lệ xảy ra, chương trình sẽ tạo ra một đối tượng Exception và ném (throw) nó vào một nơi xử lý thích hợp để ứng dụng có thể tiếp tục hoặc kết thúc.

Phân biệt giữa Checked và Unchecked Exception:

Checked Exception:

  • Là các ngoại lệ mà trình biên dịch yêu cầu phải xử lý (bằng cách sử dụng try-catch hoặc khai báo với throws).
  • Thường là các ngoại lệ có thể dự đoán được và có khả năng xử lý, chẳng hạn như IOException, SQLException.

Ví dụ:

try {
  FileReader file = new FileReader("file.txt");
} catch (IOException e) {
  e.printStackTrace();
}

Unchecked Exception:

  • Là các ngoại lệ không bắt buộc phải xử lý, và thường xảy ra do lỗi lập trình (ví dụ: chia cho 0, truy cập mảng ngoài phạm vi, v.v.).
  • Kế thừa từ RuntimeException và không yêu cầu khai báo hay xử lý đặc biệt.

Ví dụ: NullPointerException, ArrayIndexOutOfBoundsException.

int[] arr = new int[5];
arr[10] = 50; // Throws ArrayIndexOutOfBoundsException

Các khối try, catch, finally và throw, throws có vai trò gì trong xử lý ngoại lệ?

Trong Java, các khối try, catch, finally, throw, và throws đều có vai trò quan trọng trong việc xử lý ngoại lệ:

  • try: Được sử dụng để bao bọc mã có thể gây ra ngoại lệ. Nếu một ngoại lệ xảy ra trong khối try, chương trình sẽ chuyển sang khối catch để xử lý ngoại lệ đó.
  • catch: Được dùng để bắt và xử lý ngoại lệ. Khối catch sẽ xử lý những ngoại lệ được ném ra từ khối try. Có thể có nhiều khối catch để xử lý các loại ngoại lệ khác nhau.
  • finally: Được sử dụng để đảm bảo mã trong đó luôn được thực thi, dù có xảy ra ngoại lệ hay không. Thường được sử dụng để đóng các tài nguyên như file, kết nối cơ sở dữ liệu.
  • throw: Dùng để ném một ngoại lệ do người lập trình tạo ra (thường là một đối tượng thuộc lớp Throwable). Điều này cho phép điều khiển rõ ràng về cách xử lý ngoại lệ trong ứng dụng.
  • throws: Được sử dụng trong khai báo phương thức để chỉ ra rằng phương thức có thể ném một hoặc nhiều loại ngoại lệ. Điều này yêu cầu phương thức gọi phải xử lý hoặc khai báo ngoại lệ đó.

Mỗi phần trong hệ thống xử lý ngoại lệ giúp quản lý và đảm bảo ứng dụng có thể xử lý các tình huống lỗi một cách hiệu quả và an toàn.

Tại sao nên sử dụng các ngoại lệ tùy chỉnh (Custom Exceptions)?

Sử dụng ngoại lệ tùy chỉnh trong Java (Custom Exceptions) mang lại các lợi ích sau:

  • Tạo ra các thông báo lỗi rõ ràng và dễ hiểu hơn, giúp việc xử lý và gỡ lỗi dễ dàng.
  • Cung cấp khả năng kiểm soát tốt hơn về các tình huống cụ thể trong ứng dụng.
  • Cho phép phân biệt các lỗi đặc thù và xử lý chúng chính xác.
  • Tránh phải dựa vào các ngoại lệ mặc định của Java, vì chúng có thể không đủ rõ ràng hoặc thiếu thông tin về nguyên nhân lỗi.

Nếu finally block có chứa lệnh return, kết quả sẽ như thế nào?

Khi một lệnh return xuất hiện trong finally block, nó sẽ ghi đè lên giá trị trả về từ phương thức, kể cả khi có return trong try hoặc catch block. Điều này có nghĩa là dù trước đó có xảy ra lệnh return trong các block khác hay không, giá trị trả về cuối cùng của phương thức sẽ là giá trị của lệnh return trong finally.

Tuy nhiên, điều này có thể gây ra các hành vi không mong muốn và làm cho mã trở nên khó hiểu, vì finally block thường được sử dụng để thực thi các thao tác dọn dẹp, chẳng hạn như đóng tài nguyên. Việc đặt lệnh return trong finally có thể khiến việc quản lý luồng điều khiển trở nên phức tạp hơn.

Ví dụ:

public class Return Example {
  public static void main(String[] args) {
    System.out.println("Kết quả: " + testMethod());
  }

  public static int testMethod() {
    try {
      System.out.println("Trong try block");
      return 1; // Lệnh return trong try block
    } catch (Exception e) {
      System.out.println("Trong catch block");
      return 2; // Lệnh return trong catch block (nếu có lỗi)
    } finally {
      System.out.println("Trong finally block");
      return 3; // Lệnh return trong finally block sẽ ghi đè
    }
  }
}

Giải thích đoạn code:

  1. try block: Phần này cố gắng thực thi đoạn mã. Nếu không có lỗi, chương trình sẽ thực thi lệnh return 1;.
  2. catch block: Nếu xảy ra ngoại lệ, đoạn mã trong catch sẽ chạy, và lệnh return 2; sẽ được thực thi. Trong ví dụ này, không có lỗi, nên catch block không được gọi.
  3. finally block: Phần này luôn luôn được thực thi bất kể có lỗi hay không. Khi có lệnh return trong finally, nó sẽ ghi đè lên bất kỳ giá trị trả về nào từ try hoặc catch.

Kết quả:

  • Kết quả của phương thức testMethod() sẽ là 3, mặc dù try block đã có lệnh return 1. Lý do là lệnh return 3 trong finally đã ghi đè lên giá trị trả về trước đó.

NullPointerException là gì? Các cách phòng tránh thường gặp?

NullPointerException là lỗi xảy ra khi bạn cố gắng truy cập hoặc thao tác với một đối tượng mà chưa được khởi tạo, tức là đối tượng có giá trị null. Đây là một trong những lỗi phổ biến nhất trong Java và có thể gây ra sự cố khi chạy ứng dụng.

Các cách phòng tránh:

  • Kiểm tra giá trị null: Trước khi sử dụng một đối tượng, hãy luôn kiểm tra xem nó có phải là null không.
if (object != null) {
  object.method();
}
  • Sử dụng Optional: Java 8 cung cấp lớp Optional để xử lý các giá trị có thể là null mà không cần phải kiểm tra null trực tiếp.
Optional<String> opt = Optional.ofNullable(value);
opt.ifPresent(v -> System.out.println(v));
  • Khởi tạo giá trị mặc định: Nếu có thể, hãy khởi tạo các đối tượng với giá trị mặc định thay vì để chúng là null.
String name = "Unknown"; // thay vì null
  • Sử dụng Annotations: Bạn có thể sử dụng các annotation như @NonNull@Nullable để chỉ ra các đối tượng có thể hoặc không thể có giá trị null, giúp cải thiện tính an toàn trong mã nguồn.

Bằng cách này, bạn có thể tránh được NullPointerException và cải thiện sự ổn định của ứng dụng.

Câu hỏi phỏng vấn Java liên quan đến Java Multithreading (Đa luồng trong Java)

Thread là gì? Có những cách nào để tạo thread trong Java?

Thread trong Java là một đơn vị thực thi độc lập trong một chương trình. Nó cho phép thực hiện các tác vụ đồng thời, giúp tối ưu hóa hiệu suất ứng dụng, đặc biệt khi có nhiều tác vụ cần xử lý đồng thời (ví dụ: tải dữ liệu từ server, xử lý giao diện người dùng, v.v.).

Có hai cách chính để tạo thread trong Java:

Kế thừa lớp Thread

Bạn có thể tạo một lớp con kế thừa lớp Thread và ghi đè phương thức run(). Sau đó, tạo đối tượng của lớp này và gọi phương thức start() để bắt đầu thực thi.

class MyThread extends Thread {
    public void run() {
        System.out.println("Thread is running");
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread t = new MyThread();
        t.start(); // Bắt đầu thread
    }
}

Cài đặt interface Runnable

Lớp cài đặt interface Runnable phải ghi đè phương thức run(). Bạn tạo một đối tượng của lớp này và truyền nó cho một đối tượng Thread để chạy.

class MyRunnable implements Runnable {
    public void run() {
        System.out.println("Runnable is running");
    }
}

public class Main {
    public static void main(String[] args) {
        MyRunnable runnable = new MyRunnable();
        Thread t = new Thread(runnable);
        t.start(); // Bắt đầu thread
    }
}

Sự khác biệt giữa Runnable và Thread class trong Java?

Trong Java, cả RunnableThread đều dùng để thực thi các tác vụ trong một luồng (thread), nhưng có sự khác biệt quan trọng:

  • Runnable là một interface, định nghĩa một phương thức run(). Để sử dụng Runnable, bạn cần tạo một lớp implement interface này và sau đó truyền đối tượng của lớp đó cho một đối tượng Thread để thực thi.
  • Thread là một lớp con của java.lang.Thread và đã cài sẵn phương thức run(). Bạn có thể kế thừa lớp Thread và override phương thức run(), hoặc tạo một đối tượng Thread và truyền vào một đối tượng Runnable để thực thi.

Mục đích sử dụng:

  • Runnable phù hợp hơn khi bạn muốn chia sẻ tài nguyên giữa các luồng hoặc khi bạn không cần phải kế thừa Thread (vì Java chỉ hỗ trợ kế thừa một lớp duy nhất).
  • Thread thích hợp khi bạn muốn kiểm soát trực tiếp luồng và có thể override các phương thức của Thread như start().

Ví dụ:

  • Dùng Runnable khi muốn thực hiện nhiều tác vụ mà không cần kế thừa Thread.
  • Dùng Thread khi cần thêm các chức năng đặc biệt của lớp Thread.

synchronized keyword có tác dụng gì?

synchronized trong Java được sử dụng để đảm bảo tính đồng thời (thread safety) khi nhiều thread cùng truy cập vào một tài nguyên hoặc phương thức.

Khi một phương thức hoặc khối mã được đánh dấu bằng synchronized, chỉ có một thread có thể thực thi nó tại một thời điểm. Điều này giúp ngăn chặn các lỗi khi nhiều thread truy cập và thay đổi dữ liệu cùng lúc, làm cho dữ liệu trở nên không đồng nhất.

Phân biệt giữa wait() và sleep() trong multithreading?

wait()sleep() đều liên quan đến việc tạm dừng thực thi của một luồng, nhưng chúng có mục đích và cách sử dụng khác nhau:

  • wait(): Được gọi trên một đối tượng đồng bộ, yêu cầu luồng hiện tại trả lại khóa của đối tượng và chuyển vào trạng thái chờ (waiting) cho đến khi có thông báo (notify) hoặc thông báo tất cả (notifyAll). Phương thức này cần phải được gọi trong một khối synchronized.
  • sleep(): Dừng tạm thời một luồng trong một khoảng thời gian nhất định mà không liên quan đến đối tượng đồng bộ. Phương thức này không yêu cầu phải gọi trong khối synchronized và không giải phóng bất kỳ khóa nào.

Executor Framework trong Java là gì? Khi nào nên sử dụng?

Executor Framework trong Java là một tập hợp các interface và lớp hỗ trợ quản lý và thực thi các tác vụ bất đồng bộ (asynchronous tasks) trong môi trường đa luồng. Nó bao gồm các thành phần chính như Executor, ExecutorService, và ScheduledExecutorService.

Thay vì tạo và quản lý các luồng (threads) trực tiếp, bạn có thể sử dụng Executor Framework để quản lý các tác vụ, giúp dễ dàng kiểm soát số lượng luồng, xử lý lỗi, và quản lý tài nguyên.

Nên sử dụng Executor Framework khi:

  • Quản lý nhiều tác vụ đồng thời: Khi cần thực thi nhiều tác vụ trong các luồng riêng biệt mà không phải quản lý từng luồng một cách thủ công.
  • Tăng hiệu suất: Khi ứng dụng cần thực thi nhiều công việc song song mà không phải lo lắng về việc tạo và quản lý các luồng riêng lẻ.
  • Dễ dàng mở rộng: Executor Framework hỗ trợ việc điều chỉnh số lượng luồng theo nhu cầu của ứng dụng mà không làm tăng độ phức tạp trong mã nguồn.
  • Quản lý tài nguyên hiệu quả: Thay vì tạo ra quá nhiều luồng mà không kiểm soát, Executor Framework cho phép quản lý các luồng qua các pool, giúp hạn chế việc sử dụng tài nguyên quá mức.

Deadlock là gì? Đưa ra ví dụ và giải pháp phòng tránh?

Deadlock là tình huống trong lập trình đa luồng khi hai hay nhiều luồng (threads) chờ nhau để giải phóng tài nguyên mà chúng cần, dẫn đến tình trạng các luồng này không thể tiến hành công việc tiếp theo. Điều này thường xảy ra khi mỗi luồng giữ một tài nguyên và chờ tài nguyên còn lại mà một luồng khác đang nắm giữ.

Ví dụ về Deadlock: Giả sử có hai luồng, Thread1Thread2:

  • Thread1 giữ tài nguyên A và cần tài nguyên B để tiếp tục.
  • Thread2 giữ tài nguyên B và cần tài nguyên A để tiếp tục.

Kết quả là cả hai luồng sẽ chờ nhau vô hạn, không thể tiếp tục công việc, gây ra deadlock.

Giải pháp phòng tránh:

  1. Tránh việc giữ tài nguyên lâu dài: Hạn chế việc giữ tài nguyên trong thời gian dài, và thay vào đó, chỉ giữ tài nguyên khi thực sự cần thiết.
  2. Sắp xếp thứ tự truy cập tài nguyên: Một cách đơn giản là quy định thứ tự mà các luồng phải truy cập tài nguyên. Ví dụ, tất cả các luồng phải truy cập tài nguyên A trước, sau đó mới đến tài nguyên B.
  3. Sử dụng thời gian chờ (Timeout): Khi một luồng không thể có được tài nguyên trong một khoảng thời gian nhất định, có thể hủy bỏ thao tác hoặc thử lại sau một thời gian.
  4. Sử dụng công cụ kiểm tra Deadlock: Sử dụng các công cụ như jstack trong Java để xác định các deadlock trong hệ thống và ngừng chúng.

Câu hỏi phỏng vấn Java liên quan đến Java Stream API

Stream API là gì? Tính năng nổi bật của nó trong Java 8?

Stream API trong Java 8 là một tính năng mới giúp xử lý dữ liệu theo kiểu “functional” (chức năng), cho phép thực hiện các thao tác như lọc, ánh xạ, giảm (reduce), sắp xếp, và các phép toán khác trên các collection (danh sách, tập hợp, v.v.) một cách dễ dàng và hiệu quả.

Tính năng nổi bật của Stream API:

  1. Xử lý song song: Stream API hỗ trợ xử lý dữ liệu song song (parallel processing) mà không cần phải quan tâm đến các vấn đề đồng bộ hóa, giúp tăng hiệu suất trong các tác vụ xử lý dữ liệu lớn.
  2. Chaining operations: Các thao tác trên Stream có thể được nối tiếp nhau một cách dễ dàng, giúp viết mã ngắn gọn và dễ hiểu.
  3. Lazy evaluation: Các thao tác trên Stream không được thực hiện ngay lập tức mà chỉ khi cần thiết, giúp tối ưu hóa hiệu suất, giảm thiểu việc tính toán không cần thiết.
  4. Thao tác theo kiểu functional: Stream API hỗ trợ các phép toán hàm bậc cao như map, filter, reduce, giúp xử lý dữ liệu theo kiểu hàm, dễ đọc và bảo trì mã.

Ví dụ sử dụng Stream API:

List<String> names = Arrays.asList("John", "Jane", "Adam", "Chris");
long count = names.stream()
                  .filter(name -> name.startsWith("J"))
                  .count();
System.out.println(count); // Kết quả: 2

Stream API giúp giảm thiểu mã lặp lại và làm mã dễ hiểu hơn, đồng thời cải thiện hiệu suất xử lý dữ liệu trong các tình huống cần xử lý lượng dữ liệu lớn.

Phân biệt map và flatMap trong Stream API?

map(): Là phương thức dùng để biến đổi (transform) từng phần tử của một Stream. Nó nhận một Function<T, R> để biến đổi mỗi phần tử T của Stream thành R. Kết quả sẽ là một Stream<R> chứa các phần tử đã được biến đổi.

List<String> names = Arrays.asList("John", "Jane", "Jack");
List<Integer> lengths = names.stream()
                             .map(String:: length)
                             .collect(Collectors.toList());
// lengths: [4, 4, 4]

flatMap(): Là phương thức dùng để “làm phẳng” các Stream chứa các cấu trúc dữ liệu lồng nhau. Nó nhận một Function<T, Stream<R>> và kết quả sẽ là một Stream<R>. Nói cách khác, flatMap() giúp hợp nhất nhiều Stream con thành một Stream duy nhất.

List<List<String>> namesList = Arrays.asList(
  Arrays.asList("John", "Jane"),
  Arrays.asList("Jack", "Jill")
);

List<String> allNames = namesList.stream()
                                 .flatMap(List::stream)
                                 .collect (Collectors.toList());
// allNames: ["John", "Jane", "Jack", "Jill"]

Phân biệt Map và FlatMap:

Tiêu chí   Map FlatMap
Mục đích Áp dụng một hàm chuyển đổi vào từng phần tử trong Stream. Áp dụng một hàm chuyển đổi và “phẳng hóa” kết quả về một Stream mới.
Kiểu trả về Trả về một Stream chứa các phần tử đã được chuyển đổi (vẫn là một Stream 1 chiều). Trả về một Stream có thể chứa nhiều phần tử từ một Stream khác, làm giảm cấp độ của Stream.
Đầu vào hàm Hàm nhận một đối tượng và trả về một đối tượng. Hàm nhận một đối tượng và trả về một Stream (có thể có nhiều phần tử).
Ứng dụng Thường dùng khi cần chuyển đổi từng phần tử thành một giá trị khác. Thường dùng khi kết quả của hàm chuyển đổi là một Stream, và bạn muốn hợp nhất tất cả các Stream con thành một Stream duy nhất.

Lazy evaluation trong Stream API là gì?

Lazy evaluation trong Stream API là kỹ thuật xử lý dữ liệu mà các phần tử trong stream chỉ được xử lý khi chúng thực sự cần thiết.

Điều này có nghĩa là các phép toán trên stream, như map, filter, hay flatMap, không thực hiện ngay khi chúng được gọi, mà sẽ chỉ được thực thi khi stream được “consume”, ví dụ khi gọi phương thức như collect(), forEach(), hoặc reduce().

Điều này giúp tối ưu hóa hiệu suất vì các phép toán chỉ được thực hiện khi cần thiết và có thể dừng lại sớm nếu kết quả đã được tìm thấy.

Khái niệm parallelStream là gì? Khi nào nên sử dụng parallelStream?

parallelStream là một phương thức trong Java được cung cấp bởi API Stream (từ Java 8 trở đi) để xử lý các phần tử của một Stream theo cách song song. Khi bạn gọi parallelStream trên một Collection, nó sẽ tự động chia nhỏ các phần tử và xử lý chúng trên nhiều lõi (cores) của CPU, giúp tăng tốc độ xử lý khi làm việc với lượng dữ liệu lớn.

Bạn nên sử dụng parallelStream trong các tình huống khi:

  • Dữ liệu cần xử lý là lớn và không có sự phụ thuộc giữa các phần tử, tức là các phần tử có thể được xử lý độc lập.
  • Các tác vụ trong Stream (như map, filter, reduce,…) có thể thực hiện đồng thời mà không gặp phải vấn đề về đồng bộ hóa.
  • Hệ thống của bạn có nhiều lõi (CPU cores), giúp tận dụng tối đa khả năng xử lý song song.

Tuy nhiên, cần tránh sử dụng parallelStream khi:

  • Dữ liệu có kích thước nhỏ, vì việc chia nhỏ công việc có thể gây overhead (chi phí cho việc phân chia và gộp kết quả).
  • Các tác vụ trong Stream có sự phụ thuộc lẫn nhau hoặc yêu cầu đồng bộ hóa, điều này có thể làm giảm hiệu suất khi xử lý song song.

Phân biệt findAny() và findFirst() trong Java Stream

Cả findAny()findFirst() đều được sử dụng để tìm một phần tử trong Stream, nhưng chúng có những điểm khác biệt về cách thức hoạt động và mục đích sử dụng.

Giải thích:

  • findFirst(): Trả về phần tử đầu tiên trong Stream theo thứ tự mà các phần tử xuất hiện (nếu có), hoặc Optional.empty() nếu Stream trống. findFirst() đảm bảo rằng phần tử được trả về là phần tử đầu tiên trong Stream.
  • findAny(): Trả về một phần tử bất kỳ từ Stream, có thể là bất kỳ phần tử nào trong Stream, tùy thuộc vào cách Stream được xử lý. Trong các Stream song song, findAny() có thể trả về phần tử không phải là phần tử đầu tiên.

Sự khác biệt chính:

  • findFirst() luôn đảm bảo trả về phần tử theo thứ tự xuất hiện trong Stream.
  • findAny() có thể trả về bất kỳ phần tử nào trong Stream và có thể mang lại hiệu quả tốt hơn trong các Stream song song.

filter, map, collect có vai trò gì trong Stream API?

Trong Stream API của Java, các phương thức filter, mapcollect đóng vai trò quan trọng trong việc xử lý và chuyển đổi dữ liệu theo dòng chảy (stream). Cụ thể:

filter

Dùng để lọc các phần tử trong stream theo một điều kiện nhất định. Nó nhận vào một Predicate (hàm điều kiện) và trả về một stream mới chỉ chứa các phần tử thỏa mãn điều kiện đó.

Ví dụ: Lọc các số chẵn từ một danh sách các số.

list.stream().filter(n -> n % 2 == 0).forEach(System.out::println);

map

Dùng để biến đổi mỗi phần tử trong stream thành một giá trị khác, thường là khi bạn muốn chuyển đổi kiểu dữ liệu. Nó nhận vào một Function và trả về một stream mới với các phần tử đã được biến đổi.

Ví dụ: Chuyển đổi mỗi số trong danh sách thành bình phương của nó.

list.stream().map(n -> n * n).forEach(System.out::println);

collect

Dùng để thu thập kết quả từ một stream và chuyển đổi nó thành một dạng dữ liệu khác như List, Set, hoặc Map. Đây là một phương thức terminal, nghĩa là khi gọi collect, stream sẽ bị tiêu thụ và không thể sử dụng lại.

Ví dụ: Thu thập các số chẵn vào một danh sách.

List<Integer> evenNumbers = list.stream().filter(n -> n % 2 == 0).collect(Collectors.toList());

Các câu hỏi phỏng vấn Java về tính năng nổi bật của Java 8 mới nhất

Các tính năng mới trong Java 8 như Lambda expressions, Stream API, và Functional Interfaces là gì?

Java 8 đã mang đến nhiều tính năng mới giúp nâng cao hiệu suất và khả năng lập trình theo phong cách hàm (functional programming).

Ba tính năng quan trọng nhất là:

  1. Lambda expressions: Là một cách để viết các đoạn mã ngắn gọn và rõ ràng hơn, thay thế cho việc sử dụng các lớp ẩn danh (anonymous classes). Lambda giúp biểu diễn hành vi dưới dạng một hàm, giúp việc viết mã trở nên ngắn gọn và dễ hiểu hơn. Ví dụ: (a, b) -> a + b là một lambda expression cho phép cộng hai số.
  2. Stream API: Cung cấp cách tiếp cận mới để xử lý dữ liệu tập hợp (collections) một cách linh hoạt và mạnh mẽ. Stream cho phép thực hiện các phép toán như lọc, biến đổi, và tính toán trên các tập hợp dữ liệu mà không thay đổi cấu trúc ban đầu của chúng. Các thao tác này có thể thực hiện theo kiểu tuần tự hoặc song song để tối ưu hiệu suất.
  3. Functional Interfaces: Là các interface chỉ có một phương thức trừu tượng. Chúng được sử dụng trong các lambda expressions và có thể được truyền làm đối số cho các phương thức. Ví dụ, Runnable, Callable, và Predicate là các functional interfaces.

Những tính năng này giúp cải thiện tính hiệu quả và khả năng mở rộng của mã nguồn Java, đồng thời hỗ trợ lập trình viên lập trình theo phong cách hàm.

Tìm hiểu chi tiết phiên bản Java 8 với nhiều tính năng mới đầy đủ: https://www.javatpoint.com/java-8-features

Lambda expression là gì? Nêu ví dụ sử dụng.

Lambda expression trong Java là một tính năng được giới thiệu từ Java 8, cho phép bạn truyền một đoạn mã (biểu thức hàm) mà không cần phải tạo ra một lớp ẩn (anonymous class) để triển khai một interface có đúng một phương thức trừu tượng (functional interface). Lambda expression giúp giảm bớt sự phức tạp và làm mã nguồn ngắn gọn, dễ đọc.

Cấu trúc cơ bản của Lambda expression là:

(parameters) -> expression

Ví dụ sử dụng: Giả sử bạn có một danh sách các số nguyên và muốn in ra các số chẵn trong danh sách đó, bạn có thể sử dụng Lambda expression như sau:

import java.util.Arrays;
import java.util.List;

public class LambdaExample {
  public static void main(String[] args) {
    List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);

    // Sử dụng Lambda expression để in các số chẵn
    numbers.forEach(n -> {
      if (n % 2 == 0) {
        System.out.println(n);
      }
    });
  }
}

Trong ví dụ trên, n -> { if (n % 2 == 0) System.out.println(n); } là một Lambda expression thay thế cho việc tạo một anonymous class.

Default và Static methods trong Interface là gì? Khác gì với các phương thức thông thường?

Default methodStatic method trong interface của Java là những tính năng được giới thiệu từ Java 8, nhằm cung cấp tính linh hoạt hơn trong việc phát triển và duy trì các interface.

Default method

Đây là một phương thức trong interface có phần thân (có thể có mã thực thi) và sử dụng từ khóa default. Các phương thức này cho phép interface cung cấp một cài đặt mặc định, giúp các lớp triển khai không cần phải cài đặt lại phương thức đó nếu không muốn thay đổi hành vi mặc định.

interface MyInterface {
  default void sayHello() {
    System.out.println("Hello");
  }
}

Static method

Phương thức tĩnh trong interface có thể được gọi mà không cần tạo đối tượng của interface. Các phương thức này có thể được sử dụng để cung cấp các chức năng tiện ích hoặc hỗ trợ liên quan đến interface.

interface MyInterface {
  static void printMessage() {
    System.out.println("This is a static method");
  }
}

Sự khác biệt giữa Default và Static so với phương thức thông thường:

  • Phương thức thông thường trong interface không thể có phần thân, chúng chỉ có thể khai báo mà không có mã thực thi, và yêu cầu các lớp thực thi phải cài đặt phương thức đó.
  • DefaultStatic methods có mã thực thi, trong khi phương thức thông thường thì không.

Tổng kết câu hỏi phỏng vấn Java

Trong bài viết này, chúng ta đã khám phá một số câu hỏi phỏng vấn Java phổ biến dành cho các lập trình viên Java. Các câu hỏi này không chỉ kiểm tra kiến thức chuyên môn mà còn đánh giá khả năng giải quyết vấn đề, tư duy logic và khả năng làm việc nhóm của ứng viên.

Để chuẩn bị tốt nhất cho các cuộc phỏng vấn, ứng viên cần nắm vững lý thuyết và thực hành nhiều ví dụ thực tế, đồng thời thể hiện sự linh hoạt trong việc áp dụng các kiến thức vào giải quyết các tình huống thực tế trong công việc.