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.
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:
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):
@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,Suppliergibi standart functional interface'ler checked exception bildirmez. Stream içindeIOExceptionfı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üzdenSQLException'ı uncheckedDataAccessExceptionhiyerarş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:
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(veyacatch) hangi şekilde biterse bitsin,finallyçalışır.finallynormal biterse, ifadenin sonucutry/catch'in tamamlanma biçimidir (dönülecek değer ya da fırlatılan exception korunur).finallyani biterse (kendireturn'ü veyathrow'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:
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:
// 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 = ...)önceps'i, sonrac'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()'unkigetSuppressed()içinde durur. Eskitry/finallyileclose()yazılsaydı,close()'un exception'ı asıl hatayı ezerdi ve log'da gerçek sebebi göremezdin. - Kaynak
nulliseclose()çağrılmaz. - Kaynaklar, aynı
try'ıncatchvefinallybloklarından önce kapanır.catchiç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.
Optionalya 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
OmitStackTraceInFastThrowoptimizasyonu (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 olmayanjava.lang.NullPointerExceptionsatırları görüyorsan sebep budur. İlk oluşumlar stack trace'li olduğu için log'un başına bakmak gerekir. Gerekirse-XX:-OmitStackTraceInFastThrowile 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ıpe'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@RestControllerAdviceile HTTP'ye çevir. Spring 6 / Boot 3'te gövde formatı olarakProblemDetail(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:
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:
| Tip | Ne atanabilir? | Okuma | Yazma |
|---|---|---|---|
List<? extends Number> | List<Integer>, List<Double>, List<Number> | Number olarak okunur | Yazılamaz (sadece null) |
List<? super Integer> | List<Integer>, List<Number>, List<Object> | Sadece Object olarak okunur | Integer (ve alt tipleri) yazılır |
List<?> | Her List | Object olarak okunur | Yazı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.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ıftacompareTo(Order)yazdığında, derleyicicompareTo(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()venew T[n]yazılamaz: çalışma anındaT'nin ne olduğu bilinmez. ÇözümClass<T>veyaSupplier<T>almaktır.obj instanceof List<String>yazılamaz (sadeceList<?>).void process(List<String>)vevoid process(List<Integer>)overload'ları aynı sınıfta olamaz: erasure sonrası imzaları aynıdır (name clash).- Static bir alan
Ttipinde olamaz: tüm parametrizasyonlar tek bir sınıfı paylaşır. - Generic bir sınıf
Throwable'ı extend edemez:catchbloğ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"):
// 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:
- 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. 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):
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:
"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:
- 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)kodundapathdoğ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. - Hash cache. İçerik değişmediği için
hashCodebir kez hesaplanıphashalanında saklanabilir.HashMap<String, ...>aramaları bu yüzden ucuzdur. Bu alanvolatiledeğ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ıcahashIsZeroalanı vardır. - 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.
- Thread-safety. Immutable nesne senkronizasyonsuz paylaşılabilir.
finalalan 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
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:
// 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.
String | StringBuilder | StringBuffer | |
|---|---|---|---|
| Değişebilir | Hayır | Evet | Evet |
| Thread-safe | Evet (immutable) | Hayır | Evet (her metot synchronized) |
| Kullanım | Değerler, key'ler | Tek 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:
- Kopya referans üzerinden nesneyi değiştirmek çağıranı etkiler. İki referans aynı heap nesnesini gösterir.
- Kopya referansı başka bir nesneye yönlendirmek çağıranı etkilemez. Sadece çağrılan metodun yerel slotu değişir.
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)metodununcart'ı 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.
savehiç çağrılmasa bile transaction commit edilirken Hibernate değişikliği fark eder veUPDATEatar. 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:
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 amafinally'nin ani tamamlanması onu ezer.- IllegalStateException fırlar: Exception catch'te yakalandı. Yakalanmasaydı bile
finally'dekireturnonu yutardı. Bu yüzdenfinally'dereturnbir 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ı:
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:
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.Integereklemek güvenli değildir. Sadeceproducer.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>veyaList<Object>). Hepsine birIntegergüvenle eklenebilir. - (4) derlenmez: Liste
List<Object>olabilir. Okunan değer için garanti edilen tek tipObject'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.
extendsokumaya,superyazmaya 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:
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.aile aynı pool nesnesidir. - (2) false:
cfinal 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 gelena'nın ta kendisidir. - (4) true:
equalsiçerik karşılaştırır.
Yanlış seçenekler:
true, true, true, true: (2) yanlış.cfinal 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, yania'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
hashCodeilk hesaplamadan sonrahashalanı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üzdenchar[]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
invokedynamictabanlı 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:
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ığındaoslotunaorderreferansının kopyası,qtyslotuna5değerinin kopyası yazılır.- (A)
oveorderaynı nesneyi gösteriyor.quantity = 5çağıranın nesnesinde de görünür. - (B) Yerel
oslotu yeni bir nesneyi göstermeye başlar.orderdeğişkeni etkilenmez. - (C) Yeni nesne değişir. Bu nesne metot bitince erişilemez olur ve GC'ye uygun hale gelir.
- (D) Yerel
qtykopyası değişir.q5 kalır.
Yanlış seçenekler:
1 0 5: (A) satırı orijinal nesneyi değiştirir. O andaohâlâ aynı nesneyi gösteriyor.99 100 5: Java pass-by-reference olsaydı (B) çağıranınorderdeğişkenini yeni nesneye bağlardı. Olmuyor.1 5 0:intdeğerle geçer, (D) çağıranınq'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?"
seviye: Başlangıç etiketler: [exceptions] kaynak: Tahmini
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?"
seviye: Orta etiketler: [generics, pecs] kaynak: Tahmini
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?"
seviye: Başlangıç etiketler: [string, immutability] kaynak: Tahmini
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ı?"
seviye: Başlangıç etiketler: [java-basics, pass-by-value] kaynak: Tahmini
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.
record | Lombok @Value | Lombok @Data | |
|---|---|---|---|
| Immutability | Sığ immutable, alanlar private final | Sığ immutable, sınıf final | Mutable (setter'lar) |
| Kalıtım | Başka sınıfı extend edemez, örtük final | final | Serbest |
| Accessor adı | total() | getTotal() | getTotal() / setTotal() |
| Ek instance alanı | Eklenemez (sadece bileşenler) | Eklenebilir | Eklenebilir |
| Builder / wither | Elle yazılır | @Builder, @With | @Builder |
| Bağımlılık | Yok | JDK sürümüyle uyumlu Lombok sürümü gerekir | Aynı |
| JPA entity | Olamaz | Olamaz (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:
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.
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:
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.
toStringvarsayı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.clonekırık bir mekanizmadır:Cloneablemetodu bildirmeyen bir marker interface'tir,clonesığ kopya yapar ve constructor çalıştırmaz. Kopya için copy constructor ya da static factory kullanılır (Effective Java, Item 13).finalizeJava 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 vejava.lang.ref.Cleanerkullanılır.wait/notifynesnenin monitor'ü tutulmadan çağrılırsaIllegalMonitorStateExceptionfırlatır. Her zaman bir koşul döngüsü içinde kullanılır (spurious wakeup). Modern kodda yerinejava.util.concurrentaraç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 > yvey > zisex > z. compare(x, y) == 0ise, herziçinxveyile karşılaştırma aynı işareti vermeli.- Önerilen (zorunlu değil):
equalsile tutarlılık.BigDecimalbunu bilinçli olarak sağlamaz:1.0ve1.00içincompareTo0,equalsfalse. Bu yüzdenTreeSetveHashSetaynı 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:
// 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ç:
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
finallyiçindereturnveyathrowyazmak. 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@Transactionalmetottan fırlatmak. Varsayılan kural checked exception'da commit eder. Unchecked yap ya darollbackForver. - Exception'ı akış kontrolü için kullanmak.
fillInStackTracemaliyetlidir.Optionalveya 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.classvermek. ElemanlarLinkedHashMapolur.TypeReferencekullan. - String'leri veya sarmalayıcı tipleri
==ile karşılaştırmak. Pool ve Integer cache yüzünden testte çalışır, production'da bozulur.equalsveyaObjects.equalskullan. toLowerCase()/toUpperCase()'i locale'siz çağırmak. Türk locale'inde I/ı/İ dönüşümü anahtar karşılaştırmalarını bozar.Locale.ROOTver.- Döngüde
+=ile String birleştirmek. O(n²) kopyalama.StringBuilder,joiningya da doğrudan birWriter'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
UPDATEatar. Hesaplamayı DTO üzerinde yap. - Comparator'ı çıkarma ile yazmak. Taşma işareti ters çevirir, TimSort sözleşme ihlali exception'ı fırlatabilir.
Comparator.comparingIntkullan.
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
- JLS 21 §14.20: The try statement: finally'nin tamamlanma kuralları ve try-with-resources'ın tam çevirisi.
- JLS 21 §15.29: Constant Expressions: Hangi String ifadelerinin derleme anında katlandığının kesin tanımı.
- Oracle Java Tutorials: Guidelines for Wildcard Use: "in" ve "out" değişken mantığıyla wildcard seçimi.
- JEP 280: Indify String Concatenation:
+birleştirmesinin Java 9'dan beri nasıl derlendiği ve neyi optimize ettiği. - JEP 254: Compact Strings: String'in iç temsilinin neden ve nasıl değiştiği.
- JEP 358: Helpful NullPointerExceptions: NPE mesajlarının neyin null olduğunu nasıl söylediği.
- String (Java 21 API): intern, toLowerCase(Locale) ve hashCode'un resmi tanımları.
- Comparator (Java 21 API): Sözleşme ve equals ile tutarlılık uyarısı.
- Spring Framework 6.2: Rolling Back a Declarative Transaction: Varsayılan rollback kuralı, rollbackFor ve uygulama geneli RollbackOn ayarı.
- Effective Java, 3rd Edition (Joshua Bloch): Item 9 (try-with-resources), 31 (PECS), 32 (generic varargs), 69-77 (exception'lar) bu bölümün temelidir.