Java · Bölüm 2

Dilin ince noktaları: exception'lar, generics, String ve pass-by-value

finally ve try-with-resources'ın gerçek semantiğini, generics'te invariance/PECS/type erasure'ı, String pool ve immutability'nin mekanizmasını, döngüde birleştirmenin maliyetini ve Java'nın neden her zaman pass-by-value olduğunu kod okuma sorularını çözebilecek derinlikte öğretir.

~40 dk okumaQuiz: j5, j6, j7, j8, j10
Cevaplar:

Bu bölümde

Bu bölümdeki konuların ortak özelliği, mülakatta neredeyse her zaman "bu kod ne yazdırır?" ya da "bu satır derlenir mi?" biçiminde sorulmalarıdır. Ezberle değil, dilin kuralının arkasındaki mekanizmayla çözülürler: finally'nin bir metodun tamamlanma biçimini nasıl ezdiği, generic tiplerin neden invariant olduğu, derleyicinin hangi String ifadelerini sabite çevirdiği, bir referansın kopyalanmasının ne anlama geldiği. Bölümün sonunda record ile Lombok, Comparator sözleşmesi, enum ile strateji ve autoboxing cache'i gibi, quiz'de olmayan ama aynı kategoride sık gelen konular var.

Konu anlatımı

Exception hiyerarşisi: checked, unchecked ve Error

Fırlatılabilen her şey Throwable'dır. Altında iki dal vardır:

text
Throwable
├── Error                      (unchecked) OutOfMemoryError, StackOverflowError, LinkageError...
└── Exception                  (checked)   IOException, SQLException, InterruptedException...
    └── RuntimeException       (unchecked) IllegalArgumentException, NullPointerException...

Checked exception, derleyicinin "ya yakala ya da imzanda throws ile bildir" diye zorladığı exception'dır. RuntimeException ve Error ile alt sınıfları unchecked'tir. Kritik ayrıntı: bu ayrım sadece derleyicinin kuralıdır. JVM bytecode seviyesinde checked ve unchecked arasında hiçbir fark gözetmez. Generic bir cast ile checked bir exception'ı throws bildirmeden fırlatmak mümkündür ("sneaky throw", Lombok'un @SneakyThrows'u böyle çalışır):

java
@SuppressWarnings("unchecked")
static <E extends Throwable> void sneakyThrow(Throwable e) throws E {
    throw (E) e;   // erasure sonrası cast kaybolur; derleyici E'yi RuntimeException olarak çıkarsar
}

Bu, kullanılması önerilen bir teknik değildir. Ama "checked exception JVM'in bir garantisi mi?" sorusunun cevabını verir: hayır.

Tasarım gerekçesi: Checked exception'lar, çağıranın makul şekilde kurtarabileceği dış koşullar için tasarlandı: dosya yok, ağ koptu. Unchecked exception'lar ise programlama hataları (null, geçersiz argüman) ve kurtarılamaz durumlar içindi. Pratikte modern Java ve Spring kodu büyük oranda unchecked exception'a yöneldi. Nedenleri:

  • Lambda'larla uyumsuzluk. Function, Supplier gibi standart functional interface'ler checked exception bildirmez. Stream içinde IOException fırlatan bir metot çağırmak, her seferinde sarmalayıp açmayı gerektirir.
  • Katmanlar arası sızıntı. Repository'nin SQLException'ı checked olsaydı, servis ve controller imzalarına kadar yayılırdı. Servis katmanı JDBC detayını bilmek zorunda kalırdı. Spring bu yüzden SQLException'ı unchecked DataAccessException hiyerarşisine çevirir.
  • Çoğu çağıran zaten kurtaramaz. Bir REST isteğinin ortasında DB bağlantısı koptuysa yapılacak tek şey hatayı yukarıya iletmek ve 5xx dönmektir.

Spring'in transaction davranışı da bu felsefeye göre ayarlanmıştır: @Transactional varsayılan olarak sadece RuntimeException ve Error'da rollback yapar, checked exception'da commit eder. Bu, EJB'den gelen "checked exception iş akışının beklenen bir sonucudur" geleneğidir. flash-sale-outbox-demo projesindeki question2/broken/InvalidOrderCheckedException bu tuzağın çalışan örneğidir. Çözümler: exception'ı unchecked yapmak, @Transactional(rollbackFor = ...) yazmak ya da Spring Framework 6.2+ ile tüm uygulama için @EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS) ayarlamak.

Checked exception ne zaman hâlâ mantıklı? Çağıranın gerçekten farklı bir dal izlemesi gereken ve bunu unutmasının bug olduğu durumlarda. Ama bu ihtiyaç modern Java'da çoğu zaman exception yerine dönüş tipiyle daha iyi ifade edilir: sealed bir PaymentResult (Approved, Declined, Pending) ve exhaustive switch, çağıranı tüm durumları ele almaya derleme anında zorlar (bkz. Bölüm 1).

InterruptedException özel bir durumdur. Yakalayıp yutarsan, thread'in "durdurulması istendi" bilgisini (interrupt flag) kaybedersin ve thread havuzunun kapanması takılır. Yeniden fırlatamıyorsan bayrağı geri koy:

java
try {
    queue.put(event);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();          // bayrağı geri koy
    throw new IllegalStateException("interrupted while publishing", e);
}

finally'nin gerçek semantiği

JLS'e göre bir try ifadesi ya normal tamamlanır ya da ani (abrupt) tamamlanır. Ani tamamlanma biçimleri: return, throw, break, continue. Kural (JLS §14.20.2):

  • try (veya catch) hangi şekilde biterse bitsin, finally çalışır.
  • finally normal biterse, ifadenin sonucu try/catch'in tamamlanma biçimidir (dönülecek değer ya da fırlatılan exception korunur).
  • finally ani biterse (kendi return'ü veya throw'u varsa), ifadenin sonucu finally'nin tamamlanma biçimidir. Önceki dönüş değeri ya da fırlatılmakta olan exception sessizce atılır.

Bu yüzden finally içinde return veya throw yazmak, OutOfMemoryError dahil her şeyi yutabilir. javac bunu varsayılan olarak uyarmaz. -Xlint:finally ile derlendiğinde uyarı verir. IntelliJ ve Sonar ise bunu varsayılan olarak işaretler.

Dönüş değeri finally'den önce sabitlenir. return x; çalıştığında x'in o anki değeri geçici bir yerel değişkene kopyalanır, sonra finally çalışır, en son bu kopya döndürülür:

java
static int primitive() {
    int x = 1;
    try {
        return x;          // 1 kopyalandı
    } finally {
        x = 2;             // kopyayı etkilemez
    }
}                          // 1 döner

static List<String> reference() {
    List<String> list = new ArrayList<>();
    try {
        return list;       // REFERANS kopyalandı
    } finally {
        list.add("finally");   // aynı nesne değişiyor
    }
}                          // ["finally"] döner

İkinci örnek pass-by-value ile aynı mantıktır: kopyalanan şey referanstır, nesne paylaşılır.

finally'nin çalışmadığı durumlar: System.exit() veya Runtime.halt() çağrısı, JVM'in çökmesi, sürecin kill -9 ile öldürülmesi (Kubernetes'te OOMKilled dahil), try içinde sonsuz döngü, JVM kapanırken hâlâ çalışan daemon thread'ler. Yani finally "JVM yaşadığı sürece" garantisidir. Kritik bir kaynağı temizlemek (ör. dağıtık kilidi bırakmak) için finally'ye güvenmek yetmez, kilidin bir TTL'i de olmalıdır.

Bytecode seviyesinde ilginç bir ayrıntı: modern javac finally bloğunu, try ve her catch bloğunun çıkış yollarına kopyalar (inline eder). Eski jsr/ret talimatları Java 7 class dosyalarında artık kullanılamaz. Bu yüzden çok büyük finally blokları bytecode boyutunu katlar.

try-with-resources ve suppressed exception

Java 7 ile gelen try-with-resources, AutoCloseable kaynakları otomatik kapatır. Derleyicinin ürettiği kod (JLS §14.20.3) kabaca şudur:

java
// try (Resource r = open()) { body }  şuna dönüşür:
final Resource r = open();
Throwable primary = null;
try {
    body;
} catch (Throwable t) {
    primary = t;
    throw t;
} finally {
    if (r != null) {
        if (primary != null) {
            try {
                r.close();
            } catch (Throwable suppressed) {
                primary.addSuppressed(suppressed);   // asıl hata korunur, ikincisi eklenir
            }
        } else {
            r.close();                               // gövde başarılıysa close'un hatası asıl hatadır
        }
    }
}

Bu kodun söylediği kurallar:

  • Kapanış sırası ters sıradır. try (Connection c = ...; PreparedStatement ps = ...) önce ps'i, sonra c'yi kapatır. Sonradan açılan kaynak öncekine bağımlı olabilir.
  • Asıl exception korunur. Gövde exception fırlatırsa ve close() da fırlatırsa, gövdenin exception'ı fırlar. close()'unki getSuppressed() içinde durur. Eski try/finally ile close() yazılsaydı, close()'un exception'ı asıl hatayı ezerdi ve log'da gerçek sebebi göremezdin.
  • Kaynak null ise close() çağrılmaz.
  • Kaynaklar, aynı try'ın catch ve finally bloklarından önce kapanır. catch içinde artık kapalı bir connection'a erişirsin.
  • Java 9+: Effectively final bir değişken doğrudan kullanılabilir: try (existingStream) { ... }.

Log'larda suppressed exception'lar Suppressed: başlığıyla stack trace'in altında görünür. Bir hatayı araştırırken bu satırları atlamamak gerekir. Örneğin "transaction rollback edilemedi" hatası, asıl SQL hatasının suppressed'ı olarak gelebilir.

Exception'ların maliyeti ve iyi pratikler

Exception nesnesini pahalı yapan şey new değil, constructor içinde çağrılan fillInStackTrace()'tir: thread'in tüm stack'ini yürüyüp bir dizi frame bilgisi üretir. Derin Spring çağrı zincirlerinde bu yüzlerce frame demektir. Sonuçları:

  • Akış kontrolü için exception kullanma. "Kullanıcı yoksa exception fırlat, yakala, varsayılan dön" deseni sıcak bir yolda ölçülebilir CPU maliyeti üretir. Optional ya da bir sonuç tipi kullan.
  • Stack trace'i gerekmeyen exception'lar (ör. çok sık fırlatılan, sadece akış bildiren bir validasyon hatası) için Throwable'ın (message, cause, enableSuppression, writableStackTrace) constructor'ı ile stack trace üretimi kapatılabilir. Bu bir mikro optimizasyondur, profil görmeden yapılmaz.
  • HotSpot'un OmitStackTraceInFastThrow optimizasyonu (varsayılan açık): JIT, aynı yerden çok sık fırlatılan bazı implicit exception'ları (NPE, ArithmeticException, ArrayIndexOutOfBoundsException) önceden oluşturulmuş, stack trace'siz bir nesneyle değiştirir. Production log'larında mesajı ve stack trace'i olmayan java.lang.NullPointerException satırları görüyorsan sebep budur. İlk oluşumlar stack trace'li olduğu için log'un başına bakmak gerekir. Gerekirse -XX:-OmitStackTraceInFastThrow ile kapatılır.
  • Helpful NullPointerExceptions (JEP 358): Java 14 ile geldi, Java 15'ten beri varsayılan açık. Mesaj, neyin null olduğunu söyler: Cannot invoke "Order.total()" because "order" is null.

İyi pratikler:

  • Cause'u koru. throw new OrderException("save failed", e). e.getMessage()'ı yeni exception'a yazıp e'yi atmak, stack trace'in en değerli kısmını yok eder.
  • Log'la ve fırlat, ikisini birden yapma. Her katman log'larsa aynı hata log'da beş kez görünür. Hatayı ya ele alan yerde log'la ya da en üstte (global handler) bir kez.
  • catch (Exception e) {} yazma. Yutulan exception, production'daki en pahalı bug türüdür. En azından log'la.
  • Domain exception hiyerarşisi kur (OrderNotFoundException, InsufficientStockException) ve @RestControllerAdvice ile HTTP'ye çevir. Spring 6 / Boot 3'te gövde formatı olarak ProblemDetail (RFC 7807 ve onun yerini alan RFC 9457 formatı) kullanılabilir.

Generics: invariance, wildcard ve PECS

Generic tipler invariant'tır. Integer, Number'ın alt tipidir ama List<Integer>, List<Number>'ın alt tipi değildir. Öyle olsaydı şu kod derlenirdi:

java
List<Integer> ints = new ArrayList<>();
List<Number> nums = ints;        // derleme hatası; derlenseydi:
nums.add(3.14);                  // Integer listesine Double girerdi
Integer i = ints.get(0);         // ClassCastException

Diziler ise kovaryanttır ve bu, Java 1.0'dan kalan bir tasarım hatası olarak kabul edilir. Integer[] bir Object[]'dir, bu yüzden aynı hata derleme anında yakalanamaz ve çalışma anında ArrayStoreException olarak çıkar. Dizi her elemanın gerçek tipini bildiği için (reified) JVM her yazmada kontrol yapar. Generic'ler hatayı derleme anına taşır.

Invariance güvenlidir ama esnek değildir. sum(List<Number>) metoduna List<Integer> veremezsin. Wildcard'lar bu esnekliği yön kısıtlamasıyla geri verir:

TipNe atanabilir?OkumaYazma
List<? extends Number>List<Integer>, List<Double>, List<Number>Number olarak okunurYazılamaz (sadece null)
List<? super Integer>List<Integer>, List<Number>, List<Object>Sadece Object olarak okunurInteger (ve alt tipleri) yazılır
List<?>Her ListObject olarak okunurYazılamaz (sadece null)

Neden ? extends Number'a yazılamaz? Derleyici listenin gerçekte List<Integer> mı, List<Double> mı olduğunu bilmez. add(1) yazmana izin verseydi, List<Double> olan bir listeye Integer girebilirdi. Okumak ise güvenlidir: ne olursa olsun her eleman bir Number'dır. null yazılabilir çünkü null her referans tipinin değeridir.

Neden ? super Integer'dan okunan değer Object'tir? Liste List<Object> olabilir, içinde String bile olabilir. Garanti edilen tek şey Object olmasıdır. Yazmak ise güvenlidir: liste hangi üst tipin listesi olursa olsun, bir Integer oraya sığar.

PECS (Producer Extends, Consumer Super): Parametre sana veri üretiyorsa (sen okuyorsan) ? extends T. Senden veri tüketiyorsa (sen yazıyorsan) ? super T. Hem okuyup hem yazıyorsan wildcard kullanma, T kullan.

JDK bu kuralın örnekleriyle doludur:

java
// java.util.Collections
public static <T> void copy(List<? super T> dest, List<? extends T> src)

// Comparator'ın kendisi T'yi tüketir: Comparator<Object> bir String listesini sıralayabilir
public static <T> void sort(List<T> list, Comparator<? super T> c)

// java.util.stream.Stream
<R> Stream<R> map(Function<? super T, ? extends R> mapper)

Comparator<? super T> neden önemli? Elinde Comparator<Order> var ve listen List<FlashSaleOrder> (Order'ın alt tipi). İmza Comparator<T> olsaydı bu listeyi sıralayamazdın. ? super T sayesinde üst tip için yazılmış bir karşılaştırıcı alt tiplerin listesinde de çalışır.

İki pratik kural (Effective Java, Item 31):

  • Wildcard'ları parametrelerde kullan, dönüş tiplerinde kullanma. List<? extends Number> dönen bir metot, çağıranı wildcard ile uğraşmaya zorlar.
  • API'yi kullanan kişi wildcard'ı düşünmek zorunda kalıyorsa, API'nin tasarımı yanlıştır. Doğru wildcard'lar görünmezdir: çağıran sadece "bu çalışıyor" der.

Type erasure ve sonuçları

Generic'ler Java 5'te mevcut bytecode ve kütüphanelerle geriye uyumlu olacak şekilde eklendi. Bu yüzden tip parametreleri derleme sonrası silinir (erasure): List<String> ve List<Integer> çalışma anında aynı List sınıfıdır. Derleyici:

  • Tip parametresini sınırına (bound) çevirir: T → Object, T extends Comparable<T> → Comparable.
  • Gereken yerlere cast ekler: String s = list.get(0) aslında (String) list.get(0)'dır.
  • Override'ı korumak için gerekiyorsa bridge method üretir. Comparable<Order>'ı implemente eden sınıfta compareTo(Order) yazdığında, derleyici compareTo(Object) imzalı sentetik bir metot ekler ve cast edip gerçek metoda iletir.

Erasure'ın sonuçları, mülakatta "neden şunu yapamıyorum?" sorularının cevabıdır:

  • new T() ve new T[n] yazılamaz: çalışma anında T'nin ne olduğu bilinmez. Çözüm Class<T> veya Supplier<T> almaktır.
  • obj instanceof List<String> yazılamaz (sadece List<?>).
  • void process(List<String>) ve void process(List<Integer>) overload'ları aynı sınıfta olamaz: erasure sonrası imzaları aynıdır (name clash).
  • Static bir alan T tipinde olamaz: tüm parametrizasyonlar tek bir sınıfı paylaşır.
  • Generic bir sınıf Throwable'ı extend edemez: catch bloğu çalışma anında tipe göre eşleşir.

Tip bilgisinin tamamen kaybolmadığı yerler: Erasure, nesnelerin çalışma anı tipinden generic bilgiyi siler. Ama bildirimlerin (alan tipleri, metot imzaları, üst sınıf bildirimi) generic bilgisi class dosyasındaki Signature attribute'unda saklanır ve reflection ile okunabilir. Jackson'ın TypeReference'ı ve Spring'in ParameterizedTypeReference'ı bu boşluğu kullanır ("super type token"):

java
// Anonim alt sınıf: "TypeReference<List<OrderDto>>" üst sınıf bildirimi class dosyasında saklanır
List<OrderDto> orders = objectMapper.readValue(json, new TypeReference<List<OrderDto>>() {});

// Spring 6.1+ RestClient
List<OrderDto> fromApi = restClient.get()
        .uri("/orders")
        .retrieve()
        .body(new ParameterizedTypeReference<List<OrderDto>>() {});

Sondaki {} tesadüf değildir. Anonim bir alt sınıf oluşturur ve bu alt sınıfın getGenericSuperclass() metodu List<OrderDto>'yu döner. objectMapper.readValue(json, List.class) yazsaydın elemanlar OrderDto değil LinkedHashMap olurdu ve hata ancak elemana eriştiğinde ClassCastException olarak çıkardı.

Raw type (List yalın) tip güvenliğini tamamen kapatır ve sadece Java 5 öncesi kodla uyumluluk için vardır. Heap pollution, parametrize bir tipin değişkeninin o tipte olmayan bir nesneyi göstermesidir. En sık kaynağı generic varargs'tır (T... args aslında bir T[] oluşturur ve generic dizi güvenli değildir). Metot güvenliyse @SafeVarargs ile işaretlenir (List.of(E...) böyledir).

String pool, == ve intern

== iki referansın aynı nesneyi gösterip göstermediğine bakar. equals içeriği karşılaştırır. String için neredeyse her zaman equals gerekir. == bazen "çalışır", çünkü String pool aynı içerikli bazı String'lerin tek bir nesneyi paylaşmasını sağlar.

String pool (HotSpot'ta StringTable), kanonik String nesnelerine referans tutan bir hash tablosudur. Java 7'den beri pool'daki String nesneleri normal heap'tedir (öncesinde PermGen'deydi), bu yüzden erişilemez hale geldiklerinde GC tarafından toplanabilirler. Pool'a iki yoldan girilir:

  1. Literal'ler. Kodda geçen her "backend" literal'i class dosyasının constant pool'unda durur ve ilk kullanıldığında pool'daki kanonik nesneye çözülür. Aynı literal farklı sınıflarda geçse bile aynı nesneyi gösterir.
  2. intern(). İçerik pool'da varsa oradaki referansı döner, yoksa bu String'i pool'a ekleyip onu döner.

Derleme anı sabitleri (JLS §15.29). Derleyici, derleme anında hesaplanabilen String ifadelerini tek bir literal'e katlar (constant folding):

java
String a = "backend";
String b = "back" + "end";        // derleyici "backend" yazar -> a == b true

String c = "back";
String d = c + "end";              // c bir değişken (sabit değil) -> çalışma anında YENİ nesne -> a == d false

final String f = "back";
String g = f + "end";              // f, sabit ifadeyle ilklendirilmiş final bir değişken -> sabit -> a == g true

final String h = loadPrefix();     // final ama değeri metottan geliyor -> sabit DEĞİL -> a == (h + "end") false

Bir değişkenin "constant variable" sayılması için final olması ve bir sabit ifade ile ilklendirilmesi gerekir. final tek başına yetmez. Bu kural static final String alanlar için de geçerlidir. Ayrıca derleyici bu sabitleri kullanıldığı yere kopyalar (inline eder). Başka bir modüldeki public static final String VERSION = "1.2" değişirse ve senin kodun yeniden derlenmezse eski değeri görmeye devam edersin. Kütüphane sınırlarında bu bir sürüm uyumluluk tuzağıdır.

== neden "testte çalışıyordu"? Testte kampanya kodu muhtemelen koddaki bir literal'le karşılaştırılıyordu ve ikisi de pool'daki aynı nesneydi. Production'da değer HTTP gövdesinden, Kafka mesajından veya veritabanından gelir. Bu String'ler çalışma anında byte[]'den oluşturulur, pool'da değildir ve == false döner. Doğru karşılaştırma:

java
"FLASH50".equals(code);              // null-safe: code null ise false
Objects.equals(code, expected);       // iki taraf da null olabilirse
code.equalsIgnoreCase("flash50");

Türkçe için kritik bir not: toLowerCase() ve toUpperCase() parametresiz çağrıldığında JVM'in varsayılan locale'ini kullanır. Türk locale'inde "TITLE".toLowerCase() sonucu "tıtle", "id".toUpperCase() sonucu "İD" olur. Bu, anahtar ve enum karşılaştırmalarını sessizce bozar (Status.valueOf(input.toUpperCase()) Türkçe locale'li bir sunucuda "İD" arar). Makine için üretilen karşılaştırmalarda her zaman toLowerCase(Locale.ROOT) kullan. Kullanıcıya gösterilen Türkçe metinde ise Locale.forLanguageTag("tr") bilinçli bir seçimdir.

intern() ne zaman kullanılır? Neredeyse hiç. Milyonlarca tekrarlı değerin (ülke kodu, sipariş durumu) uzun süre bellekte tutulduğu çok özel durumlarda bellek kazancı sağlayabilir, ama StringTable boyutu ve arama maliyeti hesaba katılmalıdır. Daha iyi alternatifler: kapalı bir değer kümesi için enum, ya da JVM'in String deduplication özelliği (-XX:+UseStringDeduplication). Bu özellik aynı içerikli String'lerin iç byte[] dizilerini GC sırasında arka planda birleştirir. Önce G1 ile geldi (JDK 8u20, JEP 192), JDK 18'den beri Serial, Parallel ve ZGC'de de desteklenir. Nesneleri değil sadece dizileri birleştirir, bu yüzden == davranışını değiştirmez.

String neden immutable?

String'in içi (JDK 9+) kabaca şöyledir: private final byte[] value, karakter kodlamasını belirten byte coder, ve hash'i saklayan int hash alanı. Hiçbir public metot value'yu değiştirmez. Değiştiriyormuş gibi görünen her metot (concat, replace, toUpperCase, substring) yeni bir String döner. Bu tasarımın nedenleri:

  1. Güvenlik. String'ler sınıf adları, dosya yolları, URL'ler, SQL parametreleri ve izin kontrollerinde kullanılır. if (isAllowed(path)) open(path) kodunda path doğrulandıktan sonra başka bir thread tarafından değiştirilebilseydi, doğrulamanın anlamı kalmazdı (TOCTOU açığı). ClassLoader'ın yüklediği sınıfın adının yükleme sırasında değişmesi de aynı türden bir açık olurdu.
  2. Hash cache. İçerik değişmediği için hashCode bir kez hesaplanıp hash alanında saklanabilir. HashMap<String, ...> aramaları bu yüzden ucuzdur. Bu alan volatile değildir. İki thread aynı anda hesaplarsa ikisi de aynı değeri yazar. Hesaplama deterministik olduğu için bu yarış zararsızdır (benign data race). Hash değeri gerçekten 0 olan String'lerin her seferinde yeniden hesaplanmaması için yeni JDK'larda ayrıca hashIsZero alanı vardır.
  3. Pool'un var olabilmesi. Aynı literal'i kullanan yüzlerce sınıf tek bir nesneyi paylaşıyor. Biri o nesneyi değiştirebilseydi hepsinin değeri değişirdi.
  4. Thread-safety. Immutable nesne senkronizasyonsuz paylaşılabilir. final alan semantiği yayınlanma (publication) güvenliğini de sağlar.

Tarihsel bir not: Java 7u6'dan önce substring, orijinal String'in char[] dizisini paylaşırdı. 100 MB'lık bir dosya içeriğinden 10 karakterlik bir substring tutmak, 100 MB'ı bellekte tutmak demekti. Bu sızıntı yüzünden substring artık her zaman kopyalar.

Döngüde birleştirme, StringBuilder ve JEP 280

java
String csv = "";
for (Order o : orders) {
    csv += o.id() + ";" + o.total() + "\n";
}

csv += x aslında csv = csv + x'tir. String immutable olduğu için her tur yeni bir String oluşturulur ve şimdiye kadarki tüm içerik bu yeni nesneye kopyalanır. Satır başına ortalama L karakter ve n satır varsa, k'ıncı turda yaklaşık k·L karakter kopyalanır. Toplam kopyalama L·(1 + 2 + ... + n) ≈ L·n²/2 olur. 200.000 satır ve satır başına 30 karakter için bu yaklaşık 6·10¹¹ karakter kopyalama demektir. Üstüne her tur büyüyen bir çöp nesne bırakır, büyük olanlar G1'de "humongous" nesne olarak doğrudan old bölgelere yerleşir ve GC baskısı artar.

JEP 280 (Java 9+) bunu düzeltmez. Java 9 öncesinde javac her + ifadesini bir StringBuilder zincirine çeviriyordu. Java 9'dan beri ifadeyi bir invokedynamic çağrısına çevirir ve birleştirme stratejisi çalışma anında StringConcatFactory tarafından seçilir. Bu, tek bir ifadenin içindeki birleştirmeyi optimize eder (doğru boyutta tek bir dizi ayırır, ara nesne üretmez). Döngünün her turu yine ayrı bir ifadedir ve yine tüm birikmiş içeriği kopyalar. Karmaşıklık O(n²) kalır.

Doğru çözümler:

java
// 1) StringBuilder: tek bir büyüyen tampon, amortize O(n)
StringBuilder sb = new StringBuilder(orders.size() * 32);   // tahmini kapasite, yeniden boyutlandırmayı azaltır
for (Order o : orders) {
    sb.append(o.id()).append(';').append(o.total()).append('\n');
}
String csv = sb.toString();

// 2) Collectors.joining: içeride StringBuilder kullanır
String csv2 = orders.stream()
        .map(o -> o.id() + ";" + o.total())
        .collect(Collectors.joining("\n", "", "\n"));

// 3) En iyisi: büyük çıktıyı belleğe hiç toplama, doğrudan yaz
try (BufferedWriter w = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
    for (Order o : orders) {
        w.write(o.id() + ";" + o.total());   // tek ifade: JEP 280 burada işini yapar
        w.newLine();
    }
}

StringBuilder varsayılan olarak 16 karakter kapasiteyle başlar ve dolduğunda yaklaşık iki katına büyür (yeni dizi + kopya). Büyüme geometrik olduğu için toplam kopyalama maliyeti amortize O(n)'dir. Kapasite tahmini verilirse kopya sayısı daha da azalır.

StringStringBuilderStringBuffer
DeğişebilirHayırEvetEvet
Thread-safeEvet (immutable)HayırEvet (her metot synchronized)
KullanımDeğerler, key'lerTek thread'de birleştirme (varsayılan seçim)Eski kod. Paylaşılan bir builder zaten kötü tasarımdır

StringBuffer'ın her metodunun senkronize olması, aynı tamponu birden fazla thread'in anlamlı şekilde kullanabileceği anlamına gelmez: iki thread'in append çağrıları birbirinin arasına girer. Bu yüzden pratikte kullanılmaz.

Compact Strings (JEP 254)

Java 9 öncesinde String içeride char[] tutuyordu: her karakter 2 byte (UTF-16). Heap dump analizleri, tipik uygulamalarda String'lerin heap'in büyük bir kısmını oluşturduğunu ve çoğunun sadece Latin-1 karakter içerdiğini gösterdi. Java 9 ile iç temsil byte[] + coder oldu:

  • İçeriğin tamamı Latin-1'e (ISO-8859-1) sığıyorsa coder = LATIN1, karakter başına 1 byte.
  • Tek bir karakter bile sığmıyorsa coder = UTF16, tüm String için karakter başına 2 byte.

Türkçe için pratik sonuç: ç, ö, ü Latin-1'de vardır, ama ş, ğ, ı, İ yoktur. "Çocuk" 1 byte/karakter ile, "Kışlık" 2 byte/karakter ile saklanır. Bu tamamen şeffaftır, length() ve charAt() aynı davranır. Özellik -XX:-CompactStrings ile kapatılabilir ama kapatmak için neredeyse hiçbir neden yoktur.

length()'in döndürdüğü şey hâlâ UTF-16 kod birimi sayısıdır, karakter sayısı değil. Emoji gibi BMP dışındaki karakterler iki kod birimi (surrogate pair) kaplar. Kullanıcı adı veya ürün başlığı uzunluk sınırı uygularken codePointCount kullanmak gerekir. Aksi halde substring bir emojiyi ortadan bölüp geçersiz bir String üretebilir.

Java her zaman pass-by-value'dur

Metot çağrıldığında JVM, argümanların değerlerini çağrılan metodun stack frame'indeki yerel değişken slotlarına kopyalar. Primitive için değer sayının kendisidir. Nesne için değer bir referanstır (nesneyi gösteren bir işaretçi). Kopyalanan şey referanstır, nesne değil. Java'da "nesneyi geçirmek" diye bir işlem yoktur, sadece referans değerini geçirmek vardır.

Bunun iki sonucu var:

  1. Kopya referans üzerinden nesneyi değiştirmek çağıranı etkiler. İki referans aynı heap nesnesini gösterir.
  2. Kopya referansı başka bir nesneye yönlendirmek çağıranı etkilemez. Sadece çağrılan metodun yerel slotu değişir.
java
static void swap(Order a, Order b) {   // Java'da yazılamayan metot
    Order tmp = a;
    a = b;
    b = tmp;                           // sadece yerel kopyalar yer değiştirdi
}

Pass-by-reference'ın turnusol testi bu swap metodudur. C++'ta referans parametrelerle yazılabilir, Java'da yazılamaz.

Production'daki karşılıkları:

  • Yan etkili metotlar. applyDiscount(cart) metodunun cart'ı değiştirip değiştirmediği imzadan anlaşılmaz. Değiştiriyorsa çağıranın elindeki nesne de değişir. Immutable nesneler ve dönüş değeri (Cart discounted = applyDiscount(cart)) bu belirsizliği ortadan kaldırır.
  • JPA dirty checking. Transaction içinde yüklenmiş (managed) bir entity'yi bir yardımcı metoda verirsin, metot "sadece hesaplama için" bir setter çağırır. save hiç çağrılmasa bile transaction commit edilirken Hibernate değişikliği fark eder ve UPDATE atar. Beklenmeyen UPDATE'lerin klasik kaynağı budur. Hesaplama gerekiyorsa entity değil, bir DTO/record üzerinde yapılmalı.
  • "Çıktı parametresi" deseni. Bir metottan iki değer dönmek için parametre olarak verilen bir listeyi doldurmak yerine bir record döndürülür: record PriceSummary(BigDecimal subtotal, BigDecimal discount).
  • Diziler de nesnedir. int[] parametre olarak verildiğinde elemanları değiştirilebilir, çağıran değişikliği görür.

Quiz'deki sorular: çözüm ve nedenleri

j5: finally içindeki return neyi yutar?

Senaryo:

java
static int compute() {
    try {
        throw new IllegalStateException("stok servisi cevap vermedi");
    } catch (IllegalStateException e) {
        return 1;
    } finally {
        return 2;
    }
}

Doğru cevap: 2.

try bir exception ile ani tamamlanır, catch onu yakalar ve return 1 ile yine ani tamamlanır (dönüş değeri 1 olarak sabitlenir). Sonra finally çalışır ve kendi return 2'si ile ani tamamlanır. JLS §14.20.2'ye göre finally ani tamamlandığında, tüm try ifadesinin sonucu finally'nin sonucudur. 1 sessizce atılır.

Yanlış seçenekler:

  • 1: catch'teki dönüş değeri hazırlanır ama finally'nin ani tamamlanması onu ezer.
  • IllegalStateException fırlar: Exception catch'te yakalandı. Yakalanmasaydı bile finally'deki return onu yutardı. Bu yüzden finally'de return bir hata gizleme mekanizmasıdır.
  • Derleme hatası: Kod derlenir. Quiz'deki açıklama "derleyici uyarı verir" diyor. Bu kesin olarak doğru değil: javac varsayılan ayarlarla bu kod için uyarı vermez, sadece -Xlint:finally (ya da -Xlint:all) ile derlendiğinde "finally clause cannot complete normally" uyarısı çıkar. "finally block does not complete normally" ifadesi IntelliJ'nin inspection'ıdır. Pratik sonuç aynı: bu uyarıyı CI'da hata olarak ele almak gerekir.

Arkadaki kavram: Tamamlanma biçimi (normal/ani) ve finally'nin önceliği. Production karşılığı: finally içinde return, throw veya exception fırlatabilecek bir çağrı (ör. connection.close()) asıl hatayı gizler. Log'da "Connection already closed" gibi ikincil bir hata görürsün, asıl SQL hatası kaybolur. try-with-resources bu yüzden vardır: ikincil hatayı suppressed olarak ekler, asıl hatayı korur.

Daha iyi çözüm: "Hata durumunda varsayılan değer dön" niyeti açıkça yazılmalı:

java
static int compute() {
    try {
        return stockClient.fetch();
    } catch (StockServiceException e) {
        log.warn("stock service unavailable, falling back to default", e);
        return DEFAULT_STOCK;
    }
}

j6: Wildcard'lar: hangi satırlar derlenir?

Senaryo:

java
List<? extends Number> producer = new ArrayList<Integer>();
producer.add(1);                       // (1)
Number n = producer.get(0);            // (2)

List<? super Integer> consumer = new ArrayList<Number>();
consumer.add(1);                       // (3)
Integer i = consumer.get(0);           // (4)

Doğru cevap: Sadece (2) ve (3).

  • (1) derlenmez: producer'ın gerçek tipi derleyici için "Number'ın bilinmeyen bir alt tipinin listesi"dir. List<Double> olabilir. Integer eklemek güvenli değildir. Sadece producer.add(null) derlenir.
  • (2) derlenir: Hangi alt tip olursa olsun her eleman bir Number'dır.
  • (3) derlenir: consumer, "Integer'ın bilinmeyen bir üst tipinin listesi"dir (List<Integer>, List<Number> veya List<Object>). Hepsine bir Integer güvenle eklenebilir.
  • (4) derlenmez: Liste List<Object> olabilir. Okunan değer için garanti edilen tek tip Object'tir. Object o = consumer.get(0) derlenir.

Yanlış seçenekler:

  • Hepsi derlenir: Wildcard'lar okuma veya yazma yönünü kısıtlar. (1) ve (4) bu kısıtlamalara takılır.
  • Sadece (1) ve (4): Tam tersi. extends okumaya, super yazmaya izin verir.
  • Sadece (2): (3) de derlenir. Integer, Integer'ın herhangi bir üst tipinin listesine eklenebilir.

Arkadaki kavram: Invariance ve PECS. Production karşılığı, yeniden kullanılabilir yardımcı metotların imzalarıdır. BigDecimal sumTotals(List<Order> orders) yazarsan List<FlashSaleOrder> alamazsın. List<? extends Order> yazdığında alırsın. Bir event'leri toplayan metot ise Consumer<? super OrderEvent> alarak hem OrderEvent hem Object tüketen listener'larla çalışır.

j7: String pool: hangi karşılaştırmalar true?

Senaryo:

java
String a = "backend";
String b = "back" + "end";
String c = "back";
String d = c + "end";
String e = d.intern();

a == b;        // (1)
a == d;        // (2)
a == e;        // (3)
a.equals(d);   // (4)

Doğru cevap: true, false, true, true.

  • (1) true: "back" + "end" iki literal'in birleşimidir, yani bir derleme anı sabit ifadesidir (JLS §15.29). Derleyici bunu class dosyasına tek bir "backend" literal'i olarak yazar. a ile aynı pool nesnesidir.
  • (2) false: c final değildir, dolayısıyla constant variable değildir. c + "end" çalışma anında hesaplanır ve heap'te yeni bir String nesnesi üretir. İçerik aynı, nesne farklı.
  • (3) true: d.intern(), "backend" içeriğinin pool'daki kanonik referansını döner. Bu referans, literal'den gelen a'nın ta kendisidir.
  • (4) true: equals içerik karşılaştırır.

Yanlış seçenekler:

  • true, true, true, true: (2) yanlış. c final olsaydı (final String c = "back") doğru olurdu.
  • false, false, true, true: (1) yanlış. Literal birleşimi derleme anında katlanır.
  • true, false, false, true: (3) yanlış. intern() pool'da zaten var olan nesneyi, yani a'yı döner.

Production karşılığı: Kampanya kodunun == ile karşılaştırılması, kodun yalnızca karşılaştırılan iki değerin de literal olduğu testlerde çalışması demektir. DB'den veya HTTP'den gelen değerle == her zaman false döner ve kampanya sessizce uygulanmaz. Statik analiz araçları (Sonar, IntelliJ, Error Prone) String'lerin == ile karşılaştırılmasını işaretler. Aynı türden bir tuzak sarmalayıcı tiplerde de vardır (bkz. bu bölümdeki autoboxing konusu).

j8: String neden immutable? Döngüde birleştirme

Senaryo: 200.000 satırlık CSV, döngüde csv += ... ile üretiliyor ve job dakikalar sürüyor.

Doğru seçenekler: a, b, c, f, g.

  • (a) Doğru: İçerik değişmediği için hashCode ilk hesaplamadan sonra hash alanında saklanır. String bu yüzden ucuz ve güvenli bir map key'idir.
  • (b) Doğru: Immutable nesne senkronizasyonsuz paylaşılabilir. String pool, aynı nesnenin yüzlerce yerde paylaşılmasına dayanır ve ancak immutability ile güvenlidir.
  • (c) Doğru: Doğrulanmış bir dosya yolu, URL veya sınıf adı, doğrulama ile kullanım arasında değiştirilemez (TOCTOU koruması).
  • (f) Doğru: JEP 254 (Java 9+). Latin-1'e sığan içerik 1 byte/karakter ile tutulur. Tek bir ş veya ğ tüm String'i UTF-16'ya çevirir.
  • (g) Doğru, nüansla: Şifre char[]'da tutulursa kullanım sonrası Arrays.fill(pw, '\0') ile silinebilir. String silinemez ve GC'ye kadar (heap dump'ta görülebilir halde) bellekte kalır. Console.readPassword() bu yüzden char[] döner. Nüans: Bir REST servisinde şifre zaten JSON gövdesinden String olarak gelir. Bu kural en çok, şifrenin uzun süre bellekte tutulduğu masaüstü uygulamalarında ve kriptografik anahtar materyalinde anlamlıdır.

Yanlış seçenekler:

  • (d) Yanlış: JEP 280'in invokedynamic tabanlı birleştirmesi tek bir ifadenin içini optimize eder. Döngünün her turunda büyüyen içerik yeniden kopyalanır, toplam maliyet O(n²) kalır.
  • (e) Yanlış: Tam tersi. StringBuffer'ın metotları synchronized'dır, StringBuilder'ınkiler değildir.

Daha iyi çözüm: StringBuilder (kapasite tahminiyle) ya da Collectors.joining. Ama asıl soru "200.000 satırı neden belleğe topluyoruz?" olmalıdır. CSV bir dosyaya veya HTTP yanıtına (StreamingResponseBody) doğrudan yazılırsa bellek kullanımı satır sayısından bağımsız ve sabit olur. Bu, batch job'ın heap'ini satır sayısıyla birlikte büyütmemenin yoludur.

j10: Java pass-by-reference mı?

Senaryo:

java
static void apply(Order o, int qty) {
    o.setQuantity(qty);      // (A)
    o = new Order(99L);      // (B)
    o.setQuantity(100);      // (C)
    qty = 0;                 // (D)
}

Order order = new Order(1L);
int q = 5;
apply(order, q);
System.out.println(order.getId() + " " + order.getQuantity() + " " + q);

Doğru cevap: 1 5 5.

  • apply çağrıldığında o slotuna order referansının kopyası, qty slotuna 5 değerinin kopyası yazılır.
  • (A) o ve order aynı nesneyi gösteriyor. quantity = 5 çağıranın nesnesinde de görünür.
  • (B) Yerel o slotu yeni bir nesneyi göstermeye başlar. order değişkeni etkilenmez.
  • (C) Yeni nesne değişir. Bu nesne metot bitince erişilemez olur ve GC'ye uygun hale gelir.
  • (D) Yerel qty kopyası değişir. q 5 kalır.

Yanlış seçenekler:

  • 1 0 5: (A) satırı orijinal nesneyi değiştirir. O anda o hâlâ aynı nesneyi gösteriyor.
  • 99 100 5: Java pass-by-reference olsaydı (B) çağıranın order değişkenini yeni nesneye bağlardı. Olmuyor.
  • 1 5 0: int değerle geçer, (D) çağıranın q'sunu etkilemez.

Production karşılığı: JPA'da managed entity'nin bir yardımcı metotta değiştirilip dirty checking ile sessizce UPDATE edilmesi. Bu soruyu doğru cevaplayan ama bu bağlantıyı kuramayan bir aday mülakatta yarım puan alır.

Üç seviyede cevap

Soru: "Checked ve unchecked exception arasındaki fark nedir? Kendi kodunda hangisini kullanırsın?"

Checked exception'ları yakalamak ya da throws ile bildirmek zorundayız, unchecked'leri değil. Checked exception'lar Exception'dan, unchecked'ler RuntimeException'dan türer. IOException checked, NullPointerException unchecked. Kendi exception'larımı genelde Exception'dan türetirim.

Checked exception'lar kurtarılabilir dış durumlar için, unchecked'ler programlama hataları ve iş kuralı ihlalleri için tasarlandı. Modern Spring kodunda unchecked tercih ediyorum: lambda'larla uyumlu, katman imzalarını kirletmiyor ve Spring @Transactional varsayılan olarak sadece RuntimeException'da rollback yapıyor. Checked bir exception fırlatırsam transaction commit olur. Domain exception'larımı RuntimeException'dan türetiyorum ve @RestControllerAdvice ile HTTP status'a çeviriyorum. Kaynakları try-with-resources ile kapatıyorum, cause'u her zaman koruyorum.

Ayrım sadece derleyicinin kuralı, JVM checked ile unchecked'i ayırt etmez. Benim için soru şu: çağıran bu durumu ele almak zorunda mı, ve bunu unutması bir bug mı? Öyleyse bugün exception yerine sealed bir sonuç tipi kullanmayı tercih ediyorum. Ödeme sonucu gibi beklenen dallar exception değil, veri. Exhaustive switch çağıranı derleme anında zorluyor ve stack trace maliyeti yok. Gerçekten istisnai durumlar için unchecked kullanıyorum, cause'u koruyarak sarıyorum ve sadece en üstte bir kez log'luyorum.

Operasyonel tarafta üç şeye dikkat ediyorum. Checked exception'ın @Transactional'da commit etmesi: ya exception'ı unchecked yaparım, ya rollbackFor veririm, Spring 6.2+ ile rollbackOn = ALL_EXCEPTIONS ile uygulama genelinde değiştiririm. InterruptedException'ı asla yutmam, interrupt bayrağını geri koyarım. Production log'larında stack trace'siz NPE görürsem OmitStackTraceInFastThrow'u düşünürüm ve log'un başındaki ilk oluşuma bakarım. Exception'ı akış kontrolü için kullanmam, fillInStackTrace sıcak yolda gerçekten maliyetli.

Junior cevap tanımı doğru veriyor ama "kendi exception'larımı Exception'dan türetirim" ifadesi Spring bağlamında sessiz commit hatası üretecek bir alışkanlık. Bunu fark etmiyor. Mid cevap modern pratiği ve Spring rollback kuralını biliyor, yani production'da bu hatayı yapmaz. Senior cevap soruyu bir tasarım kararına çeviriyor (beklenen dal mı, istisna mı), exception'a alternatif olarak sonuç tipini öneriyor ve gerçek operasyonel tuzakları (rollback kuralı, interrupt bayrağı, stack trace'siz NPE, maliyet) somut olarak sayıyor.

Takip sorusu: "try-with-resources'ta hem gövde hem close() exception fırlatırsa hangisini görürsün? Eski try/finally ile yazılsaydı ne görürdün?"

Soru: "PECS nedir? ? extends T ile ? super T arasındaki fark ne?"

PECS, Producer Extends Consumer Super demek. ? extends ile okuyabilirim ama ekleyemem, ? super ile ekleyebilirim ama okurken Object alırım.

Generic tipler invariant: List<Integer>, List<Number>'ın alt tipi değil. Wildcard'lar bu esnekliği yön kısıtıyla geri veriyor. ? extends Number ile liste List<Integer> da List<Double> da olabilir. Bu yüzden derleyici eklemeye izin vermez ama her eleman bir Number olarak okunabilir. ? super Integer listesi Integer'ın herhangi bir üst tipinin listesi olabilir. Integer eklemek güvenli ama okunan değer sadece Object. JDK'da Collections.copy(List<? super T> dest, List<? extends T> src) ve sort(List<T>, Comparator<? super T>) bunun örnekleri.

Kural invariance'ın sonucu. Diziler kovaryant olduğu için aynı hata runtime'da ArrayStoreException olarak çıkıyor. Generic'ler bunu derleme anına taşıyor, ama esnekliği de kaldırıyor. Wildcard'lar esnekliği "sadece okuma" ya da "sadece yazma" yönünde geri veriyor. Pratikte kuralım şu: wildcard'ları sadece parametrelerde kullanırım, dönüş tiplerinde asla. Çağıran wildcard'ı düşünmek zorunda kalıyorsa API yanlış tasarlanmıştır. Hem okuyup hem yazıyorsam düz T kullanırım.

Bunun bir de erasure tarafı var. Generic bilgi nesnelerden silinir, bildirimlerde kalır. Jackson'da List<OrderDto> deserialize ederken TypeReference, Spring RestClient'ta ParameterizedTypeReference kullanmamın sebebi bu. List.class verirsem elemanlar LinkedHashMap olur ve hata ancak elemana eriştiğimde ClassCastException olarak çıkar. Bunu bir entegrasyon testinde yakalamak, production'da yakalamaktan çok daha ucuz.

Junior cevap kısaltmayı ve sonucu ezberlemiş, "neden ekleyemem?" sorusunda takılır. Mid cevap nedeni (liste Double listesi olabilir) ve JDK örneklerini veriyor. Senior cevap kuralın kökenini (invariance, dizilerin kovaryansı) açıklıyor, API tasarımı için uygulanabilir bir kural koyuyor ve konuyu erasure üzerinden günlük bir probleme (Jackson/RestClient'ta generic tip kaybı) bağlıyor.

Takip sorusu: "List<? extends Number> listesine hiçbir şey ekleyemiyorsak null neden eklenebiliyor? List<?> ile raw List arasındaki fark ne?"

Soru: "String neden immutable? String pool nasıl çalışır?"

String immutable çünkü güvenli ve thread-safe. String pool'da aynı literal'ler tek bir kez tutulur, bu yüzden bellek tasarrufu sağlar. String'leri equals ile karşılaştırmak gerekir, == referansı karşılaştırır.

Immutability dört şeyi mümkün kılıyor: hashCode bir kez hesaplanıp cache'lenebiliyor, String'ler senkronizasyonsuz paylaşılabiliyor, pool güvenli şekilde var olabiliyor ve doğrulanmış bir dosya yolu ya da sınıf adı sonradan değiştirilemiyor. Pool Java 7'den beri heap'te. Literal'ler ve intern() edilen String'ler orada kanonik olarak tutulur. Derleme anı sabit ifadeleri (iki literal'in birleşimi, final sabit değişkenler) katlanır ve pool'daki nesneyi gösterir. Çalışma anında birleştirilen String yeni nesnedir. Döngüde += her turda tüm içeriği kopyaladığı için O(n²)'dir, StringBuilder kullanırım.

Immutability'nin en önemli nedeni güvenlik: String'ler sınıf adları, yollar ve yetki kontrollerinde kullanılıyor ve kontrol ile kullanım arasında değişebilseydi TOCTOU açığı olurdu. Hash cache, pool ve thread-safety bunun yan kazançları. Hash alanı volatile bile değil, hesaplama deterministik olduğu için yarış zararsız.

Pool'u production'da iki yerde düşünüyorum. Birincisi == hataları: literal'lerle test geçer, DB'den gelen değerle production'da patlar. Statik analizi CI'da zorunlu tutarım. İkincisi bellek: heap dump'ta milyonlarca tekrarlı String görürsem intern()'e değil önce enum'a, sonra -XX:+UseStringDeduplication'a bakarım. Dedup dizileri birleştirir, nesneleri değil, ve JDK 18'den beri G1 dışındaki GC'lerde de var. Compact Strings Türkçe için ilginç: tek bir ş veya ğ bütün String'i 2 byte/karaktere çeviriyor. Bir de locale tuzağı var: Türk locale'li bir sunucuda toUpperCase() "id"yi "İD" yapar ve Enum.valueOf'u bozar. Makine karşılaştırmalarında her zaman Locale.ROOT kullanırım.

Junior cevap doğru kelimeleri sıralıyor ama hiçbirinin mekanizmasını vermiyor. "Neden thread-safe?" ya da "pool neden immutability gerektirir?" sorularında takılır. Mid cevap nedenleri bağlantılı anlatıyor, constant folding'i ve döngü maliyetini biliyor. Kod okuma sorularını çözer. Senior cevap nedenler arasında öncelik kuruyor (güvenlik birincil), hash'in benign data race ayrıntısını biliyor ve konuyu üç somut production durumuna (== hataları, bellek optimizasyonu seçenekleri, Türk locale tuzağı) taşıyor. Türkiye'de çalışmış bir backend geliştiricisinden locale tuzağını duymak deneyim sinyalidir.

Takip sorusu: "final String c = "back"; String d = c + "end"; olsaydı "backend" == d ne dönerdi? c bir metottan dönen değerle ilklendirilseydi?"

Soru: "Java pass-by-value mı, pass-by-reference mı?"

Primitive'ler için pass-by-value, nesneler için pass-by-reference. Bu yüzden metoda verdiğim nesneyi değiştirirsem çağıran da değişikliği görür.

Java her zaman pass-by-value. Nesnelerde geçirilen değer referansın kopyası. Kopya referans üzerinden nesneyi değiştirirsem çağıran görür çünkü aynı nesne. Ama kopyayı başka nesneye yönlendirirsem (o = new Order()) çağıranın değişkeni etkilenmez. Pass-by-reference olsaydı Java'da swap(a, b) yazılabilirdi, yazılamıyor.

Pass-by-value. Çağrıda argüman değerleri çağrılan metodun frame'indeki yerel slotlara kopyalanır, nesneler için o değer bir referans. Turnusol testi swap: Java'da iki değişkenin değerini metotla değiştiremezsin.

Bunun pratikteki önemi yan etkiler. İmzaya bakarak bir metodun parametresini değiştirip değiştirmediğini anlayamazsın. Bu yüzden domain'de immutable nesneler ve dönüş değeri tercih ederim. JPA'da bunun pahalı bir versiyonu var: managed bir entity'yi yardımcı bir metoda verirsin, metot hesaplama için bir setter çağırır ve save hiç çağrılmadığı halde dirty checking commit'te UPDATE atar. Kod review'da entity alan "hesaplama" metotlarını özellikle kontrol ederim. Ayrıca finally'deki return değeri de aynı mantıkla çalışır: dönen şey referansın kopyası, finally nesneyi değiştirirse çağıran değişmiş nesneyi görür.

Junior cevap çok yaygın bir yanlış inancı tekrarlıyor: "nesneler pass-by-reference". Gözlemi doğru (çağıran değişikliği görür) ama modeli yanlış ve o = new Order() satırının etkisini yanlış tahmin eder. Mid cevap modeli düzeltiyor ve swap testini biliyor. Senior cevap bunu JVM frame seviyesine bağlıyor ve asıl önemli kısma geçiyor: yan etkili metotların riski ve JPA dirty checking ile ortaya çıkan beklenmeyen UPDATE'ler.

Takip sorusu: "Bir metoda List<Order> verip içinde list = new ArrayList<>() ve sonra list.add(...) yazarsan çağıranın listesi değişir mi? Peki list.clear() yazarsan?"

Sorulmadı ama sorulabilir

Record mı, Lombok mu?

Record (Java 16+) dilin bir özelliğidir. Lombok ise derleyicinin iç API'lerine müdahale eden bir annotation processor'dür.

recordLombok @ValueLombok @Data
ImmutabilitySığ immutable, alanlar private finalSığ immutable, sınıf finalMutable (setter'lar)
KalıtımBaşka sınıfı extend edemez, örtük finalfinalSerbest
Accessor adıtotal()getTotal()getTotal() / setTotal()
Ek instance alanıEklenemez (sadece bileşenler)EklenebilirEklenebilir
Builder / witherElle yazılır@Builder, @With@Builder
BağımlılıkYokJDK sürümüyle uyumlu Lombok sürümü gerekirAynı
JPA entityOlamazOlamaz (final, no-arg yok)Teknik olarak olabilir, önerilmez

Record'lar DTO'lar, komutlar, event'ler, query projection'ları ve value object'ler için ilk tercihtir. Jackson 2.12+ record'ları doğrudan destekler. Spring Boot 3'te @ConfigurationProperties tek constructor'lı record'larla ek annotation gerekmeden çalışır. Lombok hâlâ builder üretimi ve JPA entity'lerindeki boilerplate için kullanılır. Ama her yeni JDK sürümünde Lombok'un da güncellenmesi gerektiği unutulmamalı.

Mülakat cümlesi: "Veri taşıyan tipler için record kullanırım. Lombok'u builder ve entity boilerplate'i ile sınırlarım, entity'lerde @Data kullanmam."

Switch expression ve pattern matching

Java 14 (JEP 361) ile switch bir ifade haline geldi: ok sözdizimi (case A ->) fall-through yapmaz, değer döndürür, blok içinden değer yield ile döner. Enum üzerinde switch expression kullanıldığında derleyici tüm sabitlerin ele alındığını kontrol eder:

java
BigDecimal shippingFee(DeliveryType type) {
    return switch (type) {
        case STANDARD -> new BigDecimal("39.90");
        case EXPRESS  -> new BigDecimal("79.90");
        case PICKUP   -> BigDecimal.ZERO;
    };   // yeni bir DeliveryType eklenirse derleme hatası
}

Java 16'da instanceof pattern'i (if (obj instanceof Order o)), Java 21'de switch'te tip pattern'leri, record pattern'leri ve when guard'ları geldi (ayrıntı için bkz. Bölüm 1). Eski bir ayrıntı da sorulabilir: String üzerinde switch, derleyici tarafından önce hashCode() üzerinden bir switch'e, sonra çakışmaları ayırmak için equals() kontrollerine çevrilir.

Mülakat cümlesi: "Switch expression fall-through hatasını ortadan kaldırır ve enum/sealed tiplerde exhaustiveness kontrolü verir. default yazmamak bu kontrolü korur."

Text block

Java 15 (JEP 378) ile gelen """ blokları, çok satırlı SQL, JSON ve test fixture'larını kaçış karakteri olmadan yazmayı sağlar. Ortak girinti (incidental whitespace) otomatik kırpılır. Kapanış """'ın konumu girintiyi belirler. \ satır sonunu birleştirir, \s sondaki boşluğu korur.

java
String sql = """
        SELECT o.id, o.total
        FROM orders o
        WHERE o.status = ?
          AND o.created_at > ?
        """;

Text block yine sıradan bir String'dir: sabitse pool'a girer, formatted(...) ile doldurulabilir. Ama SQL'e değer koymak için asla formatted kullanılmaz, her zaman bind parametresi (?) kullanılır.

Mülakat cümlesi: "Text block sadece okunurluk özelliğidir. SQL'de değerleri yine bind parametreleriyle veririm."

var

Java 10 (JEP 286) ile yerel değişkenlerde tip çıkarımı geldi. var bir anahtar kelime değil, ayrılmış bir tip adıdır. Sadece ilklendirilen yerel değişkenlerde, for döngülerinde ve (Java 11+) lambda parametrelerinde kullanılabilir. Alan, metot parametresi ve dönüş tipinde kullanılamaz. Tip derleme anında sabitlenir, Java dinamik tipli olmaz.

İki tuzak: var list = new ArrayList<>(); tipi ArrayList<Object> olarak çıkarır. var ile tanımlanan değişken arayüz tipi değil, somut tip alır (var map = new HashMap<String, Long>() bir HashMap'tir, Map değil). Kural: sağ taraf tipi açıkça söylüyorsa var okunurluğu artırır. var result = service.process(); gibi tipin görünmediği yerlerde azaltır.

Mülakat cümlesi: "var derleme anı tip çıkarımıdır. Tip sağ tarafta açıkça görünüyorsa kullanırım, görünmüyorsa kullanmam."

Enum ile strateji

Enum'lar sınıftır: alan, constructor, metot ve sabite özel gövde (constant-specific body) taşıyabilir. Küçük ve kapalı bir strateji kümesi için ayrı sınıflar yazmaktan daha az törenlidir:

java
public enum CommissionPolicy {
    MARKETPLACE {
        @Override BigDecimal apply(BigDecimal amount) { return amount.multiply(new BigDecimal("0.12")); }
    },
    OWN_STOCK {
        @Override BigDecimal apply(BigDecimal amount) { return BigDecimal.ZERO; }
    };

    abstract BigDecimal apply(BigDecimal amount);
}

Strateji kümesi büyüyorsa, dışarıdan bağımlılık (repository, HTTP client) gerekiyorsa ya da yeni stratejiler kodu değiştirmeden eklenmeliyse enum yetmez. Interface + Spring bean'leri doğru seçimdir. Live coding provasındaki k1 (kargo ücreti servisi) tam olarak bu kararı sorar: yeni kargo firmaları sık eklendiği için orada interface + Spring'in List<ShippingStrategy> enjeksiyonu daha uygundur.

Enum'lar hakkında sık sorulan diğer ayrıntılar: enum tabanlı singleton serileştirme ve reflection saldırılarına karşı güvenlidir. values() her çağrıda dizinin kopyasını döner. Enum anahtarlı map için EnumMap (dizi tabanlı, çok hızlı) kullanılır. JPA'da @Enumerated(EnumType.STRING) kullanılır. ORDINAL, araya sabit eklendiğinde mevcut verinin anlamını kaydırır (flash-sale-outbox-demo'daki Order entity'si bu yüzden STRING kullanır).

Mülakat cümlesi: "Kapalı, bağımlılıksız ve küçük bir strateji kümesi için enum. Genişleyen ve bağımlılık isteyen stratejiler için interface + bean."

Object sınıfının metotları

Object'in public ve protected metotları: equals, hashCode, toString, getClass, clone, finalize, wait (üç overload), notify, notifyAll.

  • toString varsayılanı getClass().getName() + "@" + Integer.toHexString(hashCode())'dur. Buradaki identity hash bellek adresi değildir. HotSpot onu bir kez üretip nesne başlığına yazar, nesne GC tarafından taşınsa bile değişmez.
  • clone kırık bir mekanizmadır: Cloneable metodu bildirmeyen bir marker interface'tir, clone sığ kopya yapar ve constructor çalıştırmaz. Kopya için copy constructor ya da static factory kullanılır (Effective Java, Item 13).
  • finalize Java 9'da deprecated, Java 18'de (JEP 421) kaldırılmak üzere deprecated oldu. Çalışacağı garanti değildir, nesnenin toplanmasını geciktirir. Yerine try-with-resources ve java.lang.ref.Cleaner kullanılır.
  • wait/notify nesnenin monitor'ü tutulmadan çağrılırsa IllegalMonitorStateException fırlatır. Her zaman bir koşul döngüsü içinde kullanılır (spurious wakeup). Modern kodda yerine java.util.concurrent araçları kullanılır.

Mülakat cümlesi: "Object'in metotlarından override ettiğim üçü equals, hashCode ve toString. clone ve finalize'ı kullanmam."

Comparable ve Comparator sözleşmesi

compareTo/compare sözleşmesi:

  • Ters simetri: sgn(compare(x, y)) == -sgn(compare(y, x)).
  • Geçişlilik: x > y ve y > z ise x > z.
  • compare(x, y) == 0 ise, her z için x ve y ile karşılaştırma aynı işareti vermeli.
  • Önerilen (zorunlu değil): equals ile tutarlılık. BigDecimal bunu bilinçli olarak sağlamaz: 1.0 ve 1.00 için compareTo 0, equals false. Bu yüzden TreeSet ve HashSet aynı veriye farklı cevap verir.

Sözleşmeyi bozan bir karşılaştırıcı, Collections.sort/List.sort'un kullandığı TimSort'un bazı girdilerde IllegalArgumentException: Comparison method violates its general contract! fırlatmasına yol açar. Hata veri setine bağlı olduğu için testte değil production'da çıkar. En klasik sözleşme bozucu, çıkarma ile karşılaştırmadır:

java
// YANLIŞ: büyük değerlerde int taşması, işaret ters döner
Comparator<Product> byStock = (a, b) -> a.stock() - b.stock();

// DOĞRU
Comparator<Product> byStock2 = Comparator.comparingInt(Product::stock);

// Birden fazla anahtar, null güvenliği
Comparator<Order> byTotalDescThenDate = Comparator
        .comparing(Order::total, Comparator.reverseOrder())
        .thenComparing(Order::createdAt, Comparator.nullsLast(Comparator.naturalOrder()));

Comparator.comparing(o -> o.total()).reversed() derlenmez: zincirdeki reversed() hedef tipi kaybettirir ve lambda parametresi Object olarak çıkarılır. Method reference (Order::total) ya da açık tip ((Order o) -> o.total()) bu sorunu çözer.

Mülakat cümlesi: "Comparator'ı comparing/thenComparing ile kurarım, çıkarma ile asla. Sözleşme bozulursa TimSort production verisinde exception fırlatabilir."

Autoboxing ve Integer cache

Integer.valueOf(int) (autoboxing'in kullandığı metot), JLS'in gerektirdiği şekilde -128 ile 127 arasındaki değerler için önbellekteki nesneleri döner. Long, Short, Byte ve Character (0-127) için de benzer bir cache vardır. Sonuç:

java
Long a = 127L, b = 127L;
a == b;            // true  : cache'teki aynı nesne
Long c = 128L, d = 128L;
c == d;            // false : iki ayrı nesne
c.equals(d);       // true

Bankacılık ve e-ticaret kodunda bu tuzak, Long tipindeki id'lerin == ile karşılaştırılmasıyla ortaya çıkar: küçük id'li test verisinde çalışır, production'da id 128'i geçince bozulur. j7'deki String tuzağıyla aynı türdendir. Diğer autoboxing tuzakları: null bir Integer'ın int'e unboxing'i NPE fırlatır (int qty = map.get(sku);), ve sıcak döngülerde boxing gereksiz nesne üretir (Stream<Integer> yerine IntStream).

Mülakat cümlesi: "Sarmalayıcı tipleri == ile karşılaştırmam. Integer cache yüzünden küçük değerlerde çalışır, büyüklerde bozulur."

Sık yapılan hatalar

  • finally içinde return veya throw yazmak. Asıl dönüş değerini ve fırlatılan exception'ı (Error'lar dahil) sessizce yutar. finally'yi sadece temizlik için kullan, kaynaklar için try-with-resources tercih et.
  • Kaynağı try/finally içinde elle kapatmak. close()'un exception'ı asıl hatayı ezer. try-with-resources ikincil hatayı suppressed olarak ekler.
  • Exception'ı sararken cause'u atmak. new XException(e.getMessage()) stack trace'i kaybeder. new XException("...", e) yaz.
  • InterruptedException'ı yutmak. Thread'in iptal bilgisi kaybolur, havuz kapanması takılır. Thread.currentThread().interrupt() ile bayrağı geri koy.
  • Domain exception'ını Exception'dan türetmek ve @Transactional metottan fırlatmak. Varsayılan kural checked exception'da commit eder. Unchecked yap ya da rollbackFor ver.
  • Exception'ı akış kontrolü için kullanmak. fillInStackTrace maliyetlidir. Optional veya sonuç tipi kullan.
  • List<? extends T> dönen metot yazmak. Çağıranı wildcard ile uğraşmaya zorlar. Wildcard'lar parametrelerde kullanılır.
  • Raw type kullanmak. Tip güvenliği kapanır, hata çalışma anına kayar. Tip bilinmiyorsa List<?> kullan.
  • Jackson'a generic tip için List.class vermek. Elemanlar LinkedHashMap olur. TypeReference kullan.
  • String'leri veya sarmalayıcı tipleri == ile karşılaştırmak. Pool ve Integer cache yüzünden testte çalışır, production'da bozulur. equals veya Objects.equals kullan.
  • toLowerCase()/toUpperCase()'i locale'siz çağırmak. Türk locale'inde I/ı/İ dönüşümü anahtar karşılaştırmalarını bozar. Locale.ROOT ver.
  • Döngüde += ile String birleştirmek. O(n²) kopyalama. StringBuilder, joining ya da doğrudan bir Writer'a yazma.
  • "Java 9+ concat optimizasyonu döngüyü de düzeltti" sanmak. JEP 280 tek ifadeyi optimize eder, döngüde birikimi değil.
  • Nesnelerin pass-by-reference geçtiğini sanmak. Referansın kopyası geçer. Parametreyi yeniden atamak çağıranı etkilemez, nesneyi değiştirmek etkiler.
  • Managed JPA entity'sini "hesaplama" için değiştirmek. Dirty checking commit'te UPDATE atar. Hesaplamayı DTO üzerinde yap.
  • Comparator'ı çıkarma ile yazmak. Taşma işareti ters çevirir, TimSort sözleşme ihlali exception'ı fırlatabilir. Comparator.comparingInt kullan.

Hızlı tekrar

S: finally içinde return olan bir metotta catch'teki return 1 neden kaybolur?

C: finally ani tamamlandığında (return/throw), tüm try ifadesinin sonucu finally'nin sonucu olur (JLS 14.20.2). Önceki dönüş değeri ve fırlatılmakta olan exception sessizce atılır.

S: try { return x; } finally { x = 2; } ile int bir x için ne döner?

C: Eski değer. return çalıştığında değer geçici bir yerele kopyalanır, finally onu etkilemez. Referans tiplerde ise referans kopyalandığı için finally'nin nesneye yaptığı değişiklik görünür.

S: try-with-resources'ta hem gövde hem close() exception fırlatırsa ne olur?

C: Gövdenin exception'ı fırlar, close()'unki ona addSuppressed ile eklenir ve getSuppressed() ile okunur. Kaynaklar açılış sırasının tersiyle ve catch/finally'den önce kapanır.

S: Spring @Transactional checked exception'da ne yapar?

C: Varsayılan olarak commit eder; rollback sadece RuntimeException ve Error'da. Çözüm: unchecked exception, rollbackFor ya da Spring 6.2+ ile rollbackOn = ALL_EXCEPTIONS.

S: List<? extends Number>'a neden eleman eklenemez?

C: Liste gerçekte List<Double> da olabilir; derleyici eklenen Integer'ın güvenli olduğunu kanıtlayamaz. Okuma güvenlidir (her eleman Number). Sadece null eklenebilir.

S: PECS kuralı nedir?

C: Producer Extends, Consumer Super: parametreden okuyorsan ? extends T, ona yazıyorsan ? super T, ikisini de yapıyorsan düz T. Wildcard'lar dönüş tiplerinde kullanılmaz.

S: Type erasure yüzünden neler yapılamaz?

C: new T(), new T[n], instanceof List<String>, aynı erasure'lı iki overload, T tipinde static alan, generic Throwable alt sınıfı. Bildirimlerdeki generic bilgi Signature attribute'unda kalır; TypeReference bunu kullanır.

S: "back" + "end" == "backend" neden true, c + "end" == "backend" (c final olmayan değişken) neden false?

C: İlki derleme anı sabit ifadesidir, tek bir literal'e katlanır ve pool'daki nesneyi gösterir. İkincisi çalışma anında hesaplanır ve yeni nesne üretir. c final ve sabitle ilklendirilmiş olsaydı o da true olurdu.

S: String'in immutable olmasının dört nedeni?

C: Güvenlik (doğrulanmış değer sonradan değişmez), hashCode cache'i, String pool'un güvenle paylaşılabilmesi ve senkronizasyonsuz thread-safety.

S: Java 9'un invokedynamic tabanlı birleştirmesi döngüdeki +='i neden düzeltmez?

C: JEP 280 tek bir ifadenin içindeki birleştirmeyi optimize eder. Döngünün her turu ayrı bir ifadedir ve birikmiş içeriği yeniden kopyalar; toplam maliyet O(n²) kalır.

S: Compact Strings Türkçe metinleri nasıl etkiler?

C: İçeriğin tamamı Latin-1'e sığarsa 1 byte/karakter tutulur. ç, ö, ü Latin-1'de var; ş, ğ, ı, İ yok. Bunlardan biri bile varsa tüm String UTF-16 (2 byte/karakter) olur.

S: Java pass-by-reference mı?

C: Hayır, her zaman pass-by-value. Nesnelerde referansın kopyası geçer: nesneyi değiştirmek çağıranı etkiler, parametreyi yeni nesneye atamak etkilemez. swap(a, b) yazılamaz.

S: Long a = 128L, b = 128L; a == b ne döner?

C: false. Autoboxing -128..127 arasını cache'ten verir; 128 iki ayrı nesnedir. Sarmalayıcı tipler equals ile karşılaştırılır.

S: (a, b) -> a.stock() - b.stock() karşılaştırıcısı neden hatalı?

C: Büyük değerlerde int taşması işareti ters çevirir ve sözleşmeyi bozar; TimSort "Comparison method violates its general contract!" fırlatabilir. Comparator.comparingInt kullanılır.

Kaynaklar