This article offers a comprehensive guide to core Java concepts, packed with carefully selected questions, real code examples, and practical insights. It's designed to help you understand what really matters, from fundamentals to real-world scenarios. We'll explore wrapper classes, advanced string handling, modern features like text blocks and records, and best practices for writing clean, efficient code.
In this section, we'll focus on core Java, specifically wrapper and string classes. We will learn why these classes are immutable and how memory optimization works for them. We will dive deeper into string handling, understanding the string pool and the intern
method, and also cover best practices for handling strings.
Additionally, we will look at:
- Conditions and Loops: Discover best practices for writing clean and readable conditional logic and loops.
- Arrays: Explore effective ways to use and manipulate arrays.
- Modern Java Features: See how Java improves readability and reduces noise with text blocks, type inference (var
), and the new records
feature, which simplifies boilerplate code.
Are you ready to dive deeper into all these concepts? Let's get started.
In Java, there are a number of wrapper classes like Integer
, Double
, and Float
, found in the java.lang
package. These classes act as wrappers around primitive data types (int
, double
, float
, etc.).
They are needed for several key reasons:
ArrayList
and for generics. You cannot store primitive values directly in a collection.Integer.parseInt()
and Double.valueOf()
.Let's look at an example. You cannot create a list of a primitive type:
// This is not allowed in Java
List<int> numbers;
Instead, you must use the wrapper class:
// We use the Integer wrapper class
List<Integer> numbers = new ArrayList<>();
// Autoboxing: the primitive int '10' is automatically converted to an Integer object
numbers.add(10);
numbers.add(20);
// Unboxing: the Integer objects are converted back to int primitives for the calculation
int sum = numbers.get(0) + numbers.get(1);
Java provides a wrapper class for each primitive type:
byte
-> Byte
short
-> Short
int
-> Integer
long
-> Long
float
-> Float
double
-> Double
char
-> Character
boolean
-> Boolean
These classes in the java.lang
package are rich with utility methods. For instance, you can convert a string to an Integer
object using Integer.valueOf("100")
or parse it to a primitive int
with Integer.parseInt()
. You can also compare values, convert to binary, and much more. Similar utilities exist for Double
(e.g., isNan
), Boolean
(e.g., parseBoolean
), and Character
(e.g., isDigit
, toUpperCase
).
Key Concepts to Remember:
- Autoboxing: Automatically converts a primitive into a wrapper object.
java
Integer num = 10; // int is converted to Integer
- Unboxing: Automatically converts a wrapper object back to a primitive.
java
int value = num; // Integer is converted to int
An important characteristic of wrapper classes is that they are immutable, just like strings. Once an object is created, its value cannot be modified.
Here are some essential best practices for working with wrapper classes.
valueOf
Over new
It's better to use static factory methods like Integer.valueOf(10)
instead of the constructor new Integer(10)
. The valueOf
method utilizes cached objects for commonly used values, which avoids unnecessary object creation and improves performance.
Autoboxing can lead to performance issues by creating unnecessary objects in loops.
Consider this code:
java
Integer sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i; // Autoboxing occurs in each iteration
}
Since Integer
objects are immutable, each addition (sum += i
) creates a new Integer
object. This is inefficient. The recommended approach is to use primitives for calculations:
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i; // No autoboxing, no new objects
}
An even faster approach for such operations is to use functional programming with streams.
parse
Methods for String-to-Primitive ConversionTo convert a string to a primitive, use methods like Integer.parseInt("123")
. Avoid using new Integer("123")
, as this constructor is deprecated and inefficient because it creates an unnecessary object.
Integer.valueOf
Integer.valueOf()
optimizes memory by caching frequently used integer values. Instead of creating a new object every time, it reuses an existing one from a cache. This reduces memory usage and improves performance.
Java maintains a cache of Integer
objects for values from -128 to 127. When Integer.valueOf(n)
is called with a number in this range, it returns a cached object.
// Values are within the cached range (-128 to 127)
Integer a = Integer.valueOf(100);
Integer b = Integer.valueOf(100);
System.out.println(a == b); // true, both reference the same cached object
// Values are outside the cached range
Integer x = Integer.valueOf(200);
Integer y = Integer.valueOf(200);
System.out.println(x == y); // false, new objects are created
This cache is implemented in a private static inner class called IntegerCache
within the Integer
class. Similar caching mechanisms exist for Byte
, Short
, Long
, Character
, and Boolean
. However, Float
and Double
do not have caching.
Immutability means an object's state cannot be modified after it's created. This provides several advantages:
- Thread Safety: Immutable objects can be shared safely across multiple threads without synchronization.
- Caching: Their values can be safely cached and reused, as seen with Integer.valueOf()
.
- Predictability: Code becomes more predictable because an object's state is guaranteed not to change.
When you try to modify an immutable object, a new object is created instead of changing the existing one.
Here's how you can create a simple immutable class: ```java public final class ImmutableExample { // final prevents subclassing private final int value; // private final prevents modification
public ImmutableExample(int value) {
this.value = value;
}
public int getValue() {
return value;
}
// No setter methods
} ```
Wrapper classes are immutable to enable the caching mechanism and ensure thread safety. When you perform an operation like x = x + 1
on an Integer
, a new Integer
object is created for the result, and the reference x
is updated to point to it. The original object remains unchanged.
| Feature | String
| StringBuffer
| StringBuilder
|
| :--- | :--- | :--- | :--- |
| Mutability | Immutable | Mutable | Mutable |
| Thread Safety | Thread-safe | Thread-safe (synchronized) | Not thread-safe |
| Performance | Slowest for modifications | Slower (due to synchronization) | Fastest for modifications |
String
: Use for fixed data that won't change, like constants or configuration keys. Since it's immutable, frequent modifications are inefficient as they create new objects each time.
java
String greeting = "Hello";
greeting = greeting + " World"; // Creates a new "Hello World" object
StringBuffer
: Use when you need mutable string operations in a multi-threaded environment. Its methods (append
, insert
, reverse
) are synchronized, ensuring thread safety.
StringBuilder
: The recommended choice for string modifications in a single-threaded environment. It's faster than StringBuffer
because it's not synchronized. Its methods are very similar to StringBuffer
.
Note on Performance: Avoid string concatenation with +
inside loops. This creates a new String
object in every iteration. Use StringBuilder
instead for much better performance.
// Inefficient
String result = "";
for (int i = 0; i < 1000; i++) {
result += i;
}
// Efficient
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append(i);
}
String result = sb.toString();
The String
class in Java is immutable for several critical reasons:
1. Security: Strings are widely used for sensitive information like passwords, network connections, and file paths. If strings were mutable, this data could be altered in memory after authentication or validation, posing a security risk.
2. Thread Safety: Because they are immutable, strings can be safely shared across multiple threads without the need for synchronization.
3. String Pooling: Immutability allows Java to implement the string pool, an optimization where string literals are stored and reused to save memory.
When you perform an operation like s.concat(" World")
, a new string object is created. The original string s
remains unchanged.
Text blocks simplify writing multi-line strings. Before text blocks, creating formatted strings like JSON or SQL was cumbersome, requiring escape characters (\n
) and concatenation.
Before Text Blocks:
java
String json = "{\n" +
" \"name\": \"John\",\n" +
" \"age\": 30\n" +
"}";
With Text Blocks:
java
String json = """
{
"name": "John",
"age": 30
}
""";
Text blocks, enclosed in triple double-quotes ("""
), preserve formatting and indentation, making the code much more readable. They are ideal for embedding JSON, HTML, or SQL in your Java code.
The string pool is a special memory area in the heap where Java stores unique string literals. The goal is to save memory by reusing the same string object for identical literals.
When you create a string literal like "hello"
, Java checks the pool. If the string already exists, Java returns a reference to the existing object. If not, it adds the string to the pool and returns a new reference.
String s1 = "java"; // "java" is added to the pool
String s2 = "java"; // The existing object from the pool is reused
System.out.println(s1 == s2); // true, both reference the same object
Important: Using new String("hello")
explicitly creates a new object on the heap, outside the string pool, even if an identical string exists in the pool. This is generally not recommended as it bypasses the memory-saving optimization.
String s1 = "hello"; // From the pool
String s2 = "hello"; // From the pool
String s3 = new String("hello"); // New object on the heap
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
The best practice is to always prefer string literals to save memory and improve performance.
intern()
MethodThe intern()
method forces a string to be stored in the string pool. If a string was created using new
, calling intern()
on it will check the pool for an equal string. If found, it returns the reference from the pool; otherwise, it adds the string to the pool.
String s1 = new String("hello"); // Created on the heap
String s2 = s1.intern(); // s2 now references "hello" from the pool
String s3 = "hello"; // s3 also references "hello" from the pool
System.out.println(s2 == s3); // true
This helps reduce duplicate objects and optimize memory.
Use .equals()
for Content Comparison: The ==
operator compares object references, not their content. To check if two strings have the same value, always use the .equals()
method.
```java
String s1 = "hello";
String s2 = new String("hello");
System.out.println(s1 == s2); // false (different objects) System.out.println(s1.equals(s2)); // true (same content) ```
Use .equalsIgnoreCase()
for Case-Insensitive Comparison: If you need to compare strings without considering case, this method is the right choice.
else if
: For mutually exclusive conditions, chain them with else if
for better readability than multiple separate if
statements.// Readable
boolean isAdultWithLicense = age > 18 && hasLicense;
boolean isTeenWithPermit = age > 16 && hasPermit && hasNoViolations;
if (isAdultWithLicense || isTeenWithPermit) {
// allowed to drive
}
- **Use Ternary Operator for Simple Cases:** For simple if-else assignments, the ternary operator (`? :`) is more concise.
java
// Verbose
if (x > 10) {
result = "high";
} else {
result = "low";
}
// Concise
result = (x > 10) ? "high" : "low";
``
- **Prefer
switchExpressions:** Modern
switchexpressions (available in recent Java versions) are powerful and readable, eliminating the need for
break` statements and reducing boilerplate.
for
Loop or Streams: For iterating over collections, the enhanced for
loop or functional streams are more readable and often faster than traditional index-based loops.StringBuilder
for Concatenation: As mentioned earlier, never use +
for string concatenation inside a loop.break
and continue
Wisely: Use break
to exit a loop early once a condition is met to avoid unnecessary iterations.parallelStream()
can significantly improve performance by distributing the work across multiple cores.Arrays.toString()
for single-dimensional arrays and Arrays.deepToString()
for multi-dimensional arrays.for
loop or streams for cleaner iteration.Arrays.equals()
(or Arrays.deepEquals()
for multi-dimensional arrays). The ==
operator only compares references.Arrays.copyOf()
to create a new copy (or a resized copy) of an array. For high-performance copying between existing arrays, System.arraycopy()
is faster as it uses a native implementation.List
Over Arrays: When you need a dynamic collection where elements can be easily added or removed, List
is almost always a better choice than an array.var
)Introduced in Java 10, local variable type inference allows you to declare local variables with var
, and the compiler infers the type from the right-hand side of the assignment.
Before var
:
java
ArrayList<String> numbers = new ArrayList<String>();
With var
:
java
var numbers = new ArrayList<String>();
This reduces redundant code and improves readability by focusing on the variable name and its value rather than the type declaration. Strong typing is still enforced at compile time.
Records, introduced as a standard feature in JDK 16, are a concise way to create immutable data carrier classes. They eliminate the vast amount of boilerplate code typically required for Java beans (constructors, getters, equals()
, hashCode()
, toString()
).
Before Records (a typical Java Bean): ```java public class Person { private final String name; private final String email; private final String phoneNumber;
public Person(String name, String email, String phoneNumber) {
this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
}
// Getters...
// equals(), hashCode(), toString()... (many lines of code)
} ```
With Records:
java
public record Person(String name, String email, String phoneNumber) {}
That's it! The compiler automatically generates a canonical constructor, public accessor methods (e.g., person.name()
), and implementations for toString()
, equals()
, and hashCode()
.
java
public record Person(String name, String email) {
public Person { // Compact constructor
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name cannot be null or blank");
}
}
}
java
public record Rectangle(double length, double width) {
public double area() { // Instance method
return length * width;
}
}
final
and cannot be changed after creation. If you need mutable state, use a regular class.java.lang.Record
). However, they can implement interfaces.Records are best used for data modeling, configuration, and lightweight domain objects. Avoid them for objects with complex business logic, mutable state, or inheritance hierarchies.
Since JDK 21, Java has enhanced pattern matching for records, which allows for elegant deconstruction of record objects.
Simple Record Pattern Matching: ```java public record Course(int id, String name) {}
// ... if (obj instanceof Course(int id, String name)) { // id and name are automatically extracted and available here System.out.println("Course ID: " + id + ", Name: " + name); } ```
Nested Record Pattern Matching (JDK 21+): This powerful feature allows deconstructing nested records in a single, readable expression. ```java record Person(String name, int age) {} record Address(String street, String city) {} record Contact(Person person, Address address) {}
// ... if (obj instanceof Contact(Person(var name, var age), Address(var street, var city))) { // name, age, street, and city are all extracted System.out.println(name + " lives in " + city); } ``` This modern feature makes working with complex, immutable data structures cleaner and more expressive.