সরাসরি পর্বে যান
পর্ব ০৮ · ৫৯-এর মধ্যে · মডিউল ২

String, StringBuilder আর immutability — Java-তে লেখা নিয়ে কাজ

Strings in Java — Immutability, the String Pool, == vs equals, StringBuilder and Unicode

Taught in Bangla, this lesson covers Java text handling: why String is immutable, the string pool and the == versus equals bug, everyday methods like substring, indexOf, split, strip and replace with their traps, when to use StringBuilder, converting with parseInt, and how length counts UTF-16 units in Bangla text and emoji.

এই পর্বে যা শিখবেন

  • String কেন immutable, আর এতে নিরাপত্তা, শেয়ার করা ও HashMap-এর কী সুবিধা
  • String pool কীভাবে কাজ করে, আর == বনাম equals-এর ক্লাসিক বাগ কোথা থেকে আসে
  • প্রতিদিনের method — substring, indexOf, split, strip, replace, join, repeat — আর তাদের ফাঁদ
  • কখন + যথেষ্ট, কখন StringBuilder লাগে, আর StringBuffer কেন পুরোনো
  • লেখা থেকে সংখ্যা (parseInt), আর বাংলা লেখায় length(), code point ও emoji-র আসল হিসাব

১ · String আসলে কী — একবার লেখা হলে আর বদলায় না

পর্ব ০৪ থেকেই আপনি "Hello, Dhaka!" লিখছেন। পর্ব ০৬-এ দেখেছেন, Java-র টাইপ দুই রকম — primitive (int, double, char…) আর reference. String দ্বিতীয় দলের: এটি একটি object, যার ভেতরে থাকে অক্ষরের একটি সারি। আরও নির্দিষ্ট করে বললে, সারিটি UTF-16 code unit-এর — প্রতিটি টুকরো একটি char. এই খুঁটিনাটি অংশ ৬-এ বাংলা লেখার সময় খুব কাজে লাগবে।

কিন্তু String-এর সবচেয়ে গুরুত্বপূর্ণ বৈশিষ্ট্য অন্য জায়গায়: একবার তৈরি হলে কোনো String-এর ভেতরের লেখা আর বদলানো যায় না। একে বলে immutable. String class-এ এমন কোনো method নেই যা নিজের অক্ষর বদলে দেয়। যেগুলো দেখে মনে হয় বদলাচ্ছে — toUpperCase(), replace(), strip() — তারা আসলে একটি নতুন String বানিয়ে ফেরত দেয়।

জাতীয় পরিচয়পত্রের (NID) কথা ভাবুন। কার্ডে নামের বানান ভুল হলে আপনি কলম দিয়ে কার্ডের ওপর ঠিক করেন না — নির্বাচন অফিস একটি নতুন কার্ড ছাপে। পুরোনো কার্ডটি যেমন ছিল তেমনই থাকে; আপনি শুধু মানিব্যাগে পুরোনোটির জায়গায় নতুনটি রাখেন। Java-র String ঠিক এমন: object হলো ছাপানো কার্ড, আর variable হলো মানিব্যাগের খোপ। s.toUpperCase() নতুন কার্ড ছাপায়; s = s.toUpperCase() লিখলে তবেই খোপে নতুন কার্ডটি ঢোকে।

নতুনদের সবচেয়ে প্রচলিত ভুলগুলোর একটি হলো name.trim(); লিখে ভাবা যে name পরিষ্কার হয়ে গেছে। নিচের ডায়াগ্রামে দেখুন, প্রতিটি লাইনে variable কোন object-কে দেখাচ্ছে আর heap-এ কী কী object আছে:

1String s = "cha";2s.toUpperCase();          // ফল কেউ ধরল না3System.out.println(s);4s = s.toUpperCase();      // ফল s-এ রাখা হলো5System.out.println(s);
এই মুহূর্তে
s দেখাচ্ছে
heap-এ object
  1. একটি String তৈরি। "cha" একটি string literal — কোডের ভেতরে ডাবল কোটের মধ্যে সরাসরি বসানো লেখা। JVM এর জন্য একটি String object রাখে, আর s নামের variable-এ রাখে সেই object-এর reference (ঠিকানা)। variable-এর ভেতরে লেখাটি নেই, আছে লেখাটির দিকে একটি তীর।
  2. toUpperCase() ডাকা হলো, কিন্তু ফল হারিয়ে গেল। method-টি "cha"-কে ছুঁয়েও দেখেনি; সে heap-এ একটি নতুন object "CHA" বানিয়ে ফেরত দিয়েছে। কিন্তু সেই ফেরত পাওয়া reference আমরা কোথাও রাখিনি। ফলে নতুন object-টি জন্মেই একা — কেউ তাকে দেখাচ্ছে না।
  3. ছাপা হলো cha. অনেকে এখানে CHA আশা করেন। কিন্তু s এখনো পুরোনো object-কেই দেখাচ্ছে, আর সেটি বদলায়নি — বদলানো সম্ভবই নয়। একা পড়ে থাকা "CHA" পরে garbage collection-এ মুছে যাবে।
  4. এবার ফলটি ধরে রাখা হলো। s = s.toUpperCase(); — ডান দিক আবার একটি নতুন "CHA" বানাল, আর = চিহ্ন s-এর তীরটি ঘুরিয়ে নতুন object-এর দিকে দিল। খেয়াল করুন: object বদলায়নি, variable-এর তীর বদলেছে। পুরোনো "cha" literal হিসেবে pool-এ থেকেই যায় (অংশ ২)।
  5. ছাপা হলো CHA. নিয়মটি তাহলে এক লাইনে: String-এর যেকোনো “বদলানো” method-এর ফল কোনো variable-এ না রাখলে সেটি হারিয়ে যায়। name.trim(); একা লিখলে কিছুই হয় না — লিখতে হয় name = name.trim();.

String তৈরির দুই উপায় — আর কেন new String এড়াবেন

String a = "Dhaka";               // literal — এভাবেই লিখুন
String b = new String("Dhaka");   // প্রায় কখনো দরকার হয় না

দুটি লাইনেই লেখা একই, কিন্তু দ্বিতীয়টি অপ্রয়োজনে একটি বাড়তি object বানায়: "Dhaka" literal তো আছেই, new তার একটি কপি করে heap-এ আরেকটি আলাদা object তৈরি করে। যেহেতু String বদলায় না, একই লেখার দুটি কপির কোনো লাভ নেই — শুধু মেমরি খরচ আর ==-এর বিভ্রান্তি (পরের অংশ)। নিয়ম: literal লিখুন, new String("…") নয়। char-এর array বা byte থেকে String বানাতে হলে অবশ্য constructor বা String.valueOf লাগে — সেটি আলাদা ব্যাপার।

মূল ধারণা

Immutability-র তিনটি বড় লাভ। (১) নিরাপদে শেয়ার: একই String হাজার জায়গায় দেওয়া যায় — কেউ বদলাতে পারবে না, তাই কপি করার দরকার নেই। (২) একসাথে অনেক thread: immutable object স্বাভাবিকভাবেই thread-safe (পর্ব ৪০)। (৩) Map-এর key: HashMap key-এর hashCode দেখে ঘর ঠিক করে; key বদলে গেলে সেটি ভুল ঘরে পড়ে থাকত। String বদলায় না বলে এটি সবচেয়ে ভরসাযোগ্য key (পর্ব ৩১)। দাম একটাই: লেখা বারবার বদলাতে হলে প্রতিবার নতুন object — সেই সমস্যার সমাধান StringBuilder, অংশ ৪-এ।

২ · String pool আর == বনাম equals — সবচেয়ে পুরোনো ফাঁদ

একটি বড় প্রোগ্রামে "OK", "Dhaka", "error"-এর মতো লেখা হাজারবার আসে। প্রতিবার আলাদা object বানালে মেমরি নষ্ট। তাই JVM একটি তালিকা রাখে — string pool. কোডের প্রতিটি literal এই pool থেকে আসে, আর একই লেখার literal সবসময় একই object. এটা নিরাপদ, কারণ String বদলায় না — একজন বদলে দিলে অন্যজনের লেখাও বদলে যাবে, এমন ভয় নেই।

এখান থেকেই আসে Java-র সবচেয়ে বিখ্যাত বাগ। == অপারেটর (পর্ব ০৭) reference-এর ক্ষেত্রে লেখা মেলায় না — দেখে দুটি তীর একই object-এর দিকে কি না। লেখা মেলায় equals method. দুটি literal একই object বলে == কাকতালীয়ভাবে true দেয়, আর প্রোগ্রামার ভাবেন কোড ঠিক আছে। তারপর ব্যবহারকারীর ইনপুট বা জোড়া লাগানো লেখা আসে — যা চলার সময়ে তৈরি নতুন object — আর == হঠাৎ false. ধাপে ধাপে দেখুন:

1String part = "Dha";2String a = "Dhaka";3String b = "Dhaka";4String c = new String("Dhaka");5String d = part + "ka";6print(a == b, a == c, a == d);7print(a.equals(d), a == d.intern());
মেমরির ছবি
pool-এ
pool-এর বাইরে
  1. প্রথম literal. "Dhaka" pool-এ object #1 হিসেবে আছে, a তাকে দেখায়। (লাইন ১-এর "Dha"-ও একটি literal, pool-এ আলাদা object — ছবিতে দেখানো হয়নি।)
  2. একই literal আবার। JVM নতুন object বানায় না — b-কেও #1 দেখায়। এখন a আর b দুটি variable, কিন্তু object একটিই। এ কারণেই a == b হবে true.
  3. new String — জোর করে নতুন object. new সবসময় heap-এ নতুন object (#2) বানায়, pool-এ নয়। লেখা হুবহু এক, কিন্তু ঠিকানা আলাদা।
  4. চলার সময়ে জোড়া লাগানো। part + "ka" হিসাব হয় প্রোগ্রাম চলার সময়, কারণ part একটি সাধারণ variable — compiler আগে থেকে জানে না এতে কী থাকবে। ফল একটি নতুন object #3. ব্যবহারকারীর ইনপুট, ফাইল থেকে পড়া লাইন, substring-এর ফল — সবই এমন নতুন object.
  5. তিনটি ==. a == b → true (দুজনেই #1). a == c → false (#1 বনাম #2). a == d → false (#1 বনাম #3). তিনটির লেখাই Dhaka, তবু ফল আলাদা — কারণ == লেখা দেখেনি, দেখেছে ঠিকানা।
  6. সঠিক তুলনা। a.equals(d) অক্ষর ধরে ধরে মেলায় → true. আর d.intern() pool-এ একই লেখার object খুঁজে #1 ফেরত দেয়, তাই a == d.intern()-ও true. একে বলে interning. কিন্তু লেখা মেলানোর জন্য intern নয় — সবসময় equals.

এবার নিজে চালিয়ে দেখুন। সাথে আরও দুটি তুলনা আছে: বড়-ছোট হাতের পার্থক্য উপেক্ষা করে মেলানো (equalsIgnoreCase), আর অভিধানের মতো ক্রম বলা (compareTo):

class Main {
    public static void main(String[] args) {
        String a = "Dhaka";               // literal: pool থেকে
        String b = "Dhaka";               // একই literal, একই object
        String part = "Dha";
        String d = part + "ka";           // চলার সময়ে তৈরি: নতুন object

        System.out.println("a == b          : " + (a == b));
        System.out.println("a == d          : " + (a == d));
        System.out.println("a.equals(d)     : " + a.equals(d));
        System.out.println("a == d.intern() : " + (a == d.intern()));

        String typed = "DHAKA";           // ব্যবহারকারী বড় হাতে লিখেছেন
        System.out.println("equals           : " + a.equals(typed));
        System.out.println("equalsIgnoreCase : " + a.equalsIgnoreCase(typed));

        // compareTo: ঋণাত্মক = আগে, 0 = সমান, ধনাত্মক = পরে
        System.out.println("Barishal vs Dhaka : " + "Barishal".compareTo("Dhaka"));
        System.out.println("Dhaka vs dhaka    : " + "Dhaka".compareTo("dhaka"));
        System.out.println("Dhaka vs Dhaka    : " + "Dhaka".compareTo("Dhaka"));
    }
}

প্রথম চার লাইন ডায়াগ্রামের ফলই দেখায়: true, false, true, true. মনে রাখার মতো বিষয় হলো দ্বিতীয় লাইন — a আর d দুটোতেই Dhaka, অথচ == বলছে false. লগইন ফর্মে if (input == "admin") লিখলে ঠিক এই কারণে সঠিক পাসওয়ার্ড দিয়েও কেউ ঢুকতে পারবেন না।

equalsIgnoreCase “DHAKA” আর “Dhaka”-কে সমান ধরে — শহরের নাম, ইমেইল ঠিকানা বা হ্যাঁ/না উত্তর মেলাতে কাজের। compareTo প্রথম যে জায়গায় অক্ষর আলাদা, সেখানে দুটি অক্ষরের Unicode নম্বরের বিয়োগফল দেয়: 'B' - 'D' = -2, তাই Barishal আগে। 'D' (68) আর 'd' (100)-এর পার্থক্য -32 — অর্থাৎ Java-র চোখে সব বড় হাতের অক্ষর ছোট হাতের অক্ষরের আগে। সংখ্যাটির মাপ নয়, শুধু চিহ্ন (ঋণাত্মক, শূন্য, ধনাত্মক) ভরসা করুন। নাম সাজানোয় এর ব্যবহার পর্ব ৩৩-এ।

null থেকে সাবধান। input.equals("admin") লিখলে, আর input যদি null হয়, প্রোগ্রাম NullPointerException দিয়ে থেমে যায়। দুটি নিরাপদ উপায়: literal-টি আগে লিখুন — "admin".equals(input) (null হলে শুধু false আসে) — অথবা Objects.equals(input, "admin"), যা দুই দিকের null-ই সামলায়।

C/C++/Python জানলে

C-তে char*-এ == ঠিকানা মেলায়, লেখা মেলাতে লাগে strcmp — Java-র ফাঁদটি হুবহু একই। C++-এর std::string-এ কিন্তু == লেখা মেলায়, আর সেটি বদলানোও যায় (mutable). Python-এর str Java-র মতোই immutable; পার্থক্য হলো Python-এ == লেখা মেলায় আর ঠিকানা মেলায় is — Java-র == আসলে Python-এর is-এর মতো। তুলনার জন্য দেখুন Python-এর string আর C-এর string.

৩ · দরকারি method — খোঁজা, কাটা, পরিষ্কার করা

String class-এ একশোর বেশি method আছে, কিন্তু প্রতিদিনের কাজের জন্য গোটা পনেরো জানলেই চলে। একটি কথা আগে মনে রাখুন: অক্ষরের অবস্থান (index) গোনা শুরু হয় ০ থেকে, আর অংশ ১-এর নিয়ম মেনে কোনো method মূল String বদলায় না।

Methodকী করেউদাহরণ → ফল
length()কয়টি char (UTF-16 code unit)"Java".length() → 4
charAt(i)i নম্বর ঘরের char"Java".charAt(0) → 'J'
indexOf / lastIndexOfপ্রথম / শেষ কোথায় পাওয়া গেল; না পেলে -1"Java".indexOf('a') → 1, lastIndexOf('a') → 3
containsভেতরে আছে কি না"Hello".contains("ell") → true
startsWith / endsWithশুরু / শেষ মেলে কি না"+8801712".startsWith("+880") → true
toUpperCase / toLowerCaseবড় / ছোট হাতের অক্ষরবাংলা অক্ষর যেমন ছিল তেমনই থাকে
trim / stripদুই পাশের ফাঁকা জায়গা বাদনিচে পার্থক্য দেখুন
isEmpty / isBlankদৈর্ঘ্য শূন্য? / শুধু ফাঁকা জায়গা?" ".isEmpty() → false, " ".isBlank() → true
replaceহুবহু লেখা বদলানো (regex নয়)"a.b".replace(".", "-") → a-b
splitটুকরো করে array (regex!)"a,b,c".split(",") → [a, b, c]
String.joinটুকরো জোড়া, মাঝে চিহ্নString.join(",", "Rahim", "Dhaka") → Rahim,Dhaka
repeat(n)n বার পাশাপাশি"=".repeat(5) → =====
chars()প্রতিটি char একটি সংখ্যার stream হিসেবেগোনা বা যাচাইয়ে (অংশ ৭)

substring — শুরু ধরা হয়, শেষ বাদ যায়

substring(begin, end) লেখার একটি টুকরো ফেরত দেয় — begin থেকে শুরু করে end-এর ঠিক আগ পর্যন্ত। শেষ index বাদ যায় বলে টুকরোর দৈর্ঘ্য সবসময় end - begin. নিচে “Bangladesh” শব্দের ১০টি ঘরে হাঁটুন:

1String w = "Bangladesh";2w.charAt(0)3w.substring(0, 6)4w.substring(6)5w.indexOf("desh")6w.substring(6, 11)
ঘরগুলো
index
অক্ষর
ফল
  1. দশটি ঘর, ০ থেকে ৯. “Bangladesh”-এ ১০টি অক্ষর, তাই length() 10 — কিন্তু শেষ index 9. এই “এক কম” ব্যাপারটিই অনেক ভুলের উৎস।
  2. charAt(0) — প্রথম ঘর। ফল একটি char, String নয় (একক কোট 'B'). charAt(10) চাইলে exception আসত — ১০ নম্বর ঘর নেই।
  3. substring(0, 6) — ০ থেকে ৫. ঘর ৬ (d) বাদ পড়েছে। দৈর্ঘ্য = 6 - 0 = 6 → "Bangla". শুরু ধরা হয়, শেষ বাদ — যেন বাসের টিকিট “স্টপ ৬ পর্যন্ত” মানে ৬-এ নেমে যাওয়া, ৬-এর পরে যাওয়া নয়।
  4. substring(6) — ৬ থেকে একদম শেষ। শেষ index না দিলে বাকি পুরোটা আসে → "desh". সুবিধা: substring(0, 6) + substring(6) জুড়লে হুবহু মূল শব্দ ফিরে আসে — কোনো অক্ষর দুবার বা বাদ পড়ে না।
  5. indexOf("desh") — খোঁজা। লেখাটি প্রথম যেখানে শুরু, সেই index — 6. না পেলে -1. তাই indexOf-এর ফল সরাসরি substring-এ দেওয়ার আগে -1 কি না দেখে নিন, নইলে পরের লাইনে exception.
  6. সীমার বাইরে। substring(6, 11) — ১১ নম্বর ঘর নেই। Java চুপচাপ ভুল ফল দেয় না, স্পষ্ট exception ছোড়ে: Range [6, 11) out of bounds for length 10. [6, 11) লেখাটি গণিতের নিয়মে বলছে “৬ ধরা, ১১ বাদ” — substring-এর নিয়মটিই।

trim বনাম strip — কোন “ফাঁকা” মুছবে

ফর্মে মানুষ প্রায়ই নামের আগে-পরে ফাঁকা জায়গা দেন। পুরোনো trim() শুধু সেই অক্ষরগুলো মোছে যাদের নম্বর U+0020 (সাধারণ space) বা তার কম। Java 11-এ আসা strip() Unicode-এর সব রকম ফাঁকা অক্ষর চেনে (যেটিকে Character.isWhitespace ফাঁকা বলে)। যেমন কপি-পেস্টে প্রায়ই আসা em space (U+2003): "\u2003hi\u2003"-এ trim()-এর পরেও দৈর্ঘ্য 4, strip()-এর পরে 2. সাথে আছে stripLeading() আর stripTrailing(). নতুন কোডে strip() ব্যবহার করুন, আর “কিছু লেখা হয়েছে কি না” যাচাইয়ে isBlank() (Java 11) — এটি শুধু ফাঁকা জায়গা থাকলেও true দেয়, যেখানে isEmpty() দেখে শুধু দৈর্ঘ্য শূন্য কি না।

split আর replaceAll — ভেতরে লুকানো regex

split, replaceAll আর matches যে লেখা নেয়, সেটি সাধারণ লেখা নয় — একটি regex, অর্থাৎ নকশার ভাষা। সেখানে কয়েকটি চিহ্নের বিশেষ মানে: . মানে “যেকোনো একটি অক্ষর”, | মানে “অথবা”। ফলাফল চমকে দেওয়ার মতো (Wandbox-এ যাচাই করা):

"a.b.c".split(".").length       // 0  — প্রতিটি অক্ষরই "বিভাজক"!
"a.b.c".split("\\.").length     // 3  — \\. মানে আসল ডট
"a|b|c".split("|").length       // 5  — ফাঁকা নকশা, প্রতিটি অক্ষর আলাদা
"a|b|c".split("\\|").length     // 3
"a.b".replace(".", "-")         // "a-b" — replace সাধারণ লেখা নেয়
"a.b".replaceAll(".", "-")      // "---" — replaceAll regex নেয়
"a,b,,c,,".split(",").length    // 4  — শেষের ফাঁকা টুকরো বাদ পড়ে

প্রথম লাইনে . প্রতিটি অক্ষরের সাথে মেলে, তাই সব অক্ষরই বিভাজক হয়ে যায়, টুকরোগুলো সব ফাঁকা — আর split শেষের ফাঁকা টুকরো ফেলে দেয় বলে ফল শূন্য-দৈর্ঘ্যের array. Java String-এ \ নিজেই escape অক্ষর (পর্ব ০৪), তাই regex-এর \. লিখতে হয় "\\.". শেষ লাইনটিও মনে রাখুন: CSV-র শেষে ফাঁকা ঘর থাকলে সেগুলো হারিয়ে যায়; রাখতে চাইলে split(",", -1). সন্দেহ হলে Pattern.quote(".") দিয়ে লেখাটিকে হুবহু অর্থে নিতে বলুন।

৪ · লেখা জোড়া — কখন +, কখন StringBuilder

পর্ব ০৪-এ দেখেছেন + দিয়ে লেখা জোড়া লাগে (string concatenation). এক লাইনের জোড়ায় + একদম ঠিক আছে — "Name: " + name + ", Age: " + age লিখতে দ্বিধা করবেন না। Java 9 থেকে javac এমন একটি পুরো expression-কে একটিমাত্র invokedynamic নির্দেশে compile করে (JEP 280); কোন উপায়ে জোড়া লাগবে তা JVM চলার সময় ঠিক করে, আর মাঝপথের অস্থায়ী String বানায় না। Java 8 পর্যন্ত javac একই কাজ করত পেছনে একটি StringBuilder বানিয়ে।

সমস্যা হয় লুপের ভেতরে s = s + x লিখলে। String বদলায় না, তাই প্রতিবার পুরো পুরোনো লেখা কপি করে তার সাথে নতুন টুকরো জুড়ে একটি নতুন object বানাতে হয়। দেখুন কপির হিসাব কীভাবে বাড়ে:

String s = ""; লুপে s = s + i;i = 1
heap:  ""   "1"

নতুন object: ১টি     কপি হলো: ১টি অক্ষর
String s = ""; লুপে s = s + i;i = 2
heap:  ""   "1" (আবর্জনা)   "12"

নতুন object: ২টি     মোট কপি: 1 + 2 = ৩টি অক্ষর
String s = ""; লুপে s = s + i;i = 4 পর্যন্ত
heap:  ""  "1"  "12"  "123"  "1234"

নতুন object: ৪টি     মোট কপি: 1 + 2 + 3 + 4 = ১০টি অক্ষর
n বার ঘুরলে কপি ≈ n × n ÷ 2
n = 10,000 হলে ≈ ৫ কোটি অক্ষর কপি
StringBuilder sb = new StringBuilder(); লুপে sb.append(i);একই object
sb ──► [ 1 | 2 | 3 | 4 | _ | _ | _ | _ | _ | _ | _ | _ | _ | _ | _ | _ ]
         ভরা: 4              ধারণক্ষমতা (capacity): 16

প্রতিটি append শুধু খালি ঘরে লেখে — পুরোনো অক্ষর কপি হয় না
ঘর ফুরোলে, তারপর sb.toString()শেষ ধাপ
১৭তম অক্ষর এলে:  capacity 16 → 34  (একবার কপি, তারপর আবার জায়গা)
আবার ফুরোলে:    34 → 70 …   (প্রতিবার প্রায় দ্বিগুণ)

String t = sb.toString();   → একটিমাত্র নতুন String "1234…"
n বার ঘুরলে মোট কপি ≈ কয়েক × n
  1. প্রথম ঘোরা। "" + 1 একটি নতুন String "1" বানাল, s এখন তাকে দেখায়। এখনো কোনো অপচয় চোখে পড়ে না।
  2. দ্বিতীয় ঘোরা। "1"-কে বদলানো যায় না, তাই "1"-এর অক্ষর কপি করে, পাশে 2 জুড়ে নতুন "12". পুরোনো "1" আবর্জনা — garbage collector-এর কাজ বাড়ল।
  3. চতুর্থ ঘোরা পর্যন্ত। প্রতিবার আগের পুরো লেখা কপি হচ্ছে: ১, ২, ৩, ৪… যোগফল প্রায় n²/২। লেখা যত লম্বা, প্রতি ঘোরা তত ধীর — একে বলে quadratic (বর্গ হারে বাড়া) খরচ। ১০ হাজার ঘোরায় প্রায় ৫ কোটি অক্ষর কপি; একটি বড় রিপোর্ট বানাতে গিয়ে প্রোগ্রাম কয়েক সেকেন্ড আটকে থাকে।
  4. StringBuilder — একটি বদলানো যায় এমন বাক্স। StringBuilder-এর ভেতরে একটি অক্ষরের array থাকে, শুরুতে ১৬টি ঘর (Wandbox-এ capacity() দিয়ে যাচাই করা)। append শুধু পরের খালি ঘরে লেখে; object একটিই থাকে, পুরোনো অক্ষর আর কপি হয় না।
  5. ঘর ফুরোলে বড় বাক্স, শেষে একটি String. ১৭তম অক্ষর আসতেই StringBuilder একটি বড় array নেয় (16 → 34, নিয়ম: পুরোনো × ২ + ২) আর একবার কপি করে। প্রতিবার প্রায় দ্বিগুণ হয় বলে এমন কপি খুব কম ঘটে — মোট খরচ মোটামুটি n-এর সমানুপাতিক। কাজ শেষে toString() একটিমাত্র immutable String দেয়। আগে থেকে আকার জানলে new StringBuilder(1000) দিয়ে শুরু করলে কপিও লাগে না।

StringBuilder-এর প্রচলিত method-গুলো একই object বদলায় আর সেই object-ই ফেরত দেয়, তাই পরপর শিকলের মতো লেখা যায়। ফলগুলো Wandbox-এ যাচাই করা:

// CSV লাইন বানানো — কমা শুধু মাঝখানে
String[] fields = {"Rahim", "Dhaka", "25"};
StringBuilder line = new StringBuilder();
for (String f : fields) {
    if (!line.isEmpty()) line.append(',');
    line.append(f);
}
System.out.println(line);          // Rahim,Dhaka,25
// এক লাইনেই: String.join(",", fields) → একই ফল

// একটি শব্দ উল্টানো, তারপর আরও কিছু বদল
var sb = new StringBuilder("Dhaka");
sb.reverse();                      // akahD
sb.insert(0, "[").append("]");     // [akahD]
sb.deleteCharAt(1);                // [kahD]

String

বদলায় না। শেয়ার করা, Map-এর key, method-এ পাঠানো — সব জায়গায় নিরাপদ।

বেশিরভাগ সময়

StringBuilder

বদলায়, দ্রুত, কিন্তু একাধিক thread থেকে একসাথে ব্যবহারের জন্য নয়। Java 5 থেকে আছে।

লুপে লেখা বানাতে

StringBuffer

একই কাজ, কিন্তু প্রতিটি method synchronized (Java 1.0 থেকে)। একসাথে অনেক thread নিরাপদ, তবে বাড়তি খরচ।

পুরোনো কোডে দেখবেন

StringBuffer নতুন কোডে প্রায় লাগে না — একটি লেখা সাধারণত একটি thread-ই বানায়, আর সত্যিই একাধিক thread-এর মধ্যে ভাগ করতে হলে পর্ব ৪০-এর পদ্ধতিগুলো বেশি পরিষ্কার। পুরোনো library আর বইয়ে StringBuffer প্রচুর দেখবেন, তাই নামটি চিনে রাখুন।

৫ · লেখা থেকে সংখ্যা, সংখ্যা থেকে লেখা

কীবোর্ড, ফাইল, ওয়েব ফর্ম — বাইরে থেকে যা আসে সবই লেখা। "500" আর 500 এক জিনিস নয়: প্রথমটির সাথে + 1 করলে আসে "5001". তাই রূপান্তর লাগে দুই দিকেই।

কাজলিখবেনফল
লেখা → intInteger.parseInt("500")500
লেখা → doubleDouble.parseDouble("3.5")3.5
সংখ্যা → লেখাString.valueOf(3.5)"3.5"
null → লেখাString.valueOf((Object) null)"null" (exception নয়)
সংক্ষেপে"" + 500চলে, তবে String.valueOf উদ্দেশ্য বেশি পরিষ্কার বলে

লেখাটি সংখ্যা না হলে parseInt একটি NumberFormatException ছোড়ে। Wandbox-এ Integer.parseInt("12a") চালালে আসে:

Exception in thread "main" java.lang.NumberFormatException: For input string: "12a"

মেসেজটি কোট চিহ্নের ভেতরে হুবহু লেখাটি দেখায় — এটি খুব কাজের। For input string: " 12" দেখলে বুঝবেন সামনে একটি ফাঁকা জায়গা ছিল (parseInt ফাঁকা জায়গা মেনে নেয় না — আগে strip() করুন); For input string: "" মানে ঘরটি খালি ছিল। Exception ধরে সামলানো শিখবেন পর্ব ২৯-এ; আপাতত নিয়ম — বাইরের লেখা সংখ্যায় রূপ দেওয়ার আগে পরিষ্কার আর যাচাই করুন।

বাংলা অঙ্ক — parseInt চেনে, কিন্তু regex-এর \d চেনে না

বাংলাদেশি অ্যাপে ব্যবহারকারী প্রায়ই বাংলা অঙ্কে লেখেন: "১২৩". মজার খবর হলো, Java-র Integer.parseInt প্রতিটি অক্ষরকে Character.digit দিয়ে পড়ে, যা Unicode-এর যেকোনো দশমিক অঙ্ক চেনে। Wandbox-এ (OpenJDK 21) যাচাই করা ফল:

Integer.parseInt("১২৩")            // 123
Integer.parseInt("1২3")            // 123 — মেশানো অঙ্কও চলে!
Character.isDigit('১')             // true
Character.digit('৭', 10)           // 7
"০১৭১২".matches("\\d+")            // false — regex-এর \d মানে শুধু 0-9
"01712".matches("\\d+")            // true

অর্থাৎ একই প্রোগ্রামের দুই জায়গায় দুই রকম আচরণ: regex দিয়ে “শুধু অঙ্ক কি না” যাচাই করলে বাংলা অঙ্কের নম্বর বাতিল হয়ে যাবে, অথচ parseInt সেটিকে দিব্যি পড়তে পারত। আবার উল্টো বিপদ: "1২3"-এর মতো মেশানো লেখাও গৃহীত হয়, যা হয়তো আপনি চান না। কোন অঙ্ক মানবেন, সেটি আপনার সিদ্ধান্ত — কিন্তু যাচাই আর রূপান্তর একই নিয়মে চলছে কি না, তা পরীক্ষা করে নিন।

৬ · বাংলা আর Unicode — length() আসলে কী গোনে

পর্ব ০৪-এ দেখেছেন Java শুরু থেকেই Unicode বোঝে, তাই বাংলা String-এ রাখতে কোনো ঝামেলা নেই। ঝামেলা শুরু হয় গোনার সময়। প্রশ্ন: "বাংলা".length() কত? চোখে তিনটি “অক্ষর” — বা, ং, লা। Java বলে 5.

কারণটি তিন স্তরে বোঝা যায়:

code unit (char)

Java-র String-এর মৌলিক টুকরো — ১৬ বিট। length() আর charAt() এই হিসাবে কাজ করে।

"বাংলা" → ৫টি

code point

Unicode-এর একটি অক্ষর-নম্বর: ব (U+09AC), া (U+09BE), ং, ল, া। codePoints() এগুলো গোনে।

"বাংলা" → ৫টি

দেখা অক্ষর

চোখে যা একটি অক্ষর মনে হয়। Unicode এর নিয়ম বেঁধেছে grapheme cluster নামে — কিন্তু সেই নিয়মও চোখের সাথে সবসময় মেলে না।

"বাংলা" → চোখে ৩টি, নিয়মে ২টি

বাংলায় কার-চিহ্ন (া, ি, ু…) আর হসন্ত (্) আলাদা code point. তাই "ক্ষমা".length() হলো 5 (ক, ্, ষ, ম, া), যদিও চোখে দুটি অক্ষর — ক্ষ আর মা। বাংলার সব code point Unicode-এর প্রথম ৬৫,৫৩৬টি নম্বরের মধ্যে পড়ে, তাই বাংলায় code unit আর code point-এর সংখ্যা সমান। কিন্তু emoji-র মতো অক্ষরের নম্বর এর চেয়ে বড়; UTF-16-এ সেগুলো লিখতে দুটি code unit-এর জোড়া লাগে, যাকে বলে surrogate pair. ফলে "😀".length() হলো 2, যদিও codePointCount বলে 1.

বাংলা লেখায় তিনটি ব্যবহারিক ফাঁদ (সবই Wandbox-এ যাচাই করা):

  • উল্টানো: new StringBuilder("বাংলা").reverse() দেয় ালংাব — কার-চিহ্ন এখন ভুল অক্ষরের আগে বসেছে, শব্দটি ভেঙে গেছে। StringBuilder surrogate pair ঠিক রাখে ("a😀b" উল্টে b😀a), কিন্তু বাংলার যুক্ত-চিহ্ন চেনে না।
  • অক্ষর বাছাই: Character.isLetter('া') আর Character.isLetter('ং') — দুটোই false, কারণ Unicode এদের “letter” নয়, “combining mark” (যুক্ত হওয়া চিহ্ন) হিসেবে ধরে। ফলে "বাংলা".chars().filter(Character::isLetter).count() দেয় 2. “শুধু অক্ষর রাখো” ধরনের ফিল্টার বাংলা কার-চিহ্ন মুছে ফেলবে।
  • কাটা: substring বা charAt code unit ধরে কাটে। "বাংলা".charAt(1) দেয় একা া; নামের প্রথম “অক্ষর” নিতে গিয়ে কার-চিহ্ন বা যুক্তাক্ষরের অর্ধেক কেটে যেতে পারে। SMS-এর অক্ষরসীমা বা নাম ছোট করে দেখানোর সময় সাবধান।

তাহলে “দেখা অক্ষর” গুনবেন কীভাবে? JDK-তে java.text.BreakIterator আছে, যা অক্ষরের সীমা খোঁজে, আর Java 9 থেকে regex-এর \X একটি grapheme cluster ধরে। কিন্তু ফল দেখে চমকে যাবেন: OpenJDK 21-এ \X দিয়ে গুনলে "বাংলা" হয় ২ (বাং + লা — Unicode-এর নিয়মে ং আগের অক্ষরের সাথে জুড়ে যায়), আর "ক্ষমা" হয় ৩ (ক্ + ষ + মা — যুক্তাক্ষর ভেঙে যায়)। ২০২৩ সালে Unicode 15.1 ভারতীয় লিপির যুক্তাক্ষরের জন্য নতুন নিয়ম যোগ করেছে, তাই নতুন সংস্করণে এই সংখ্যা বদলাতে পারে। জটিল লিপির নিখুঁত হিসাবের জন্য পেশাদার প্রজেক্টে প্রায়ই ICU4J-এর মতো বাইরের library ব্যবহার হয়। এই কোর্সে মূল শিক্ষাটুকুই যথেষ্ট: length() মানে মেমরির টুকরো, মানুষের চোখের অক্ষর নয়।

১৯৯১ সালে Unicode 1.0 যখন আসে, তখন ধারণা ছিল পৃথিবীর সব অক্ষর ১৬ বিটে (৬৫,৫৩৬টি নম্বরে) এঁটে যাবে। Java-র নকশা হয় সেই সময়ে, তাই ১৯৯৬ সালে Java 1.0-তে char রাখা হয় ১৬ বিটের — যাতে প্রতিটি অক্ষর এক char-এ আঁটে। কিন্তু ১৯৯৬ সালেই Unicode 2.0 স্বীকার করে নেয় যে ১৬ বিট যথেষ্ট নয়, আর surrogate pair-এর ব্যবস্থা আনে। Java-কে পুরোনো কোড না ভেঙে মানিয়ে নিতে হয়: ২০০৪ সালে Java 5-এ code point-এর method-গুলো (codePointAt, codePointCount ইত্যাদি) যোগ হয়, কিন্তু length() আর char-এর অর্থ আগের মতোই থেকে যায়। আজকের “emoji-র length 2” সেই ইতিহাসেরই ছাপ।

৭ · বাস্তব কাজ — ফোন নম্বর, পাসওয়ার্ড, SMS আর বাংলা গোনা

এবার এ পর্যন্ত শেখা সব একসাথে। নিচের প্রোগ্রামে চারটি ছোট কাজ — বাংলাদেশি অ্যাপে প্রতিদিন যা লাগে:

class Main {
    public static void main(String[] args) {
        // ১. ফোন নম্বর পরিষ্কার: +, ফাঁকা আর - বাদ
        String raw = "+880 1712-345678";
        String phone = raw.replace("+", "").replace(" ", "").replace("-", "");
        System.out.println(phone + " valid? " + (phone.length() == 13 && phone.startsWith("8801")));

        // ২. পাসওয়ার্ড নীতি: আগে-পরের ফাঁকা বাদে ৮+ char, অন্তত একটি অঙ্ক
        String pass = "  dhaka2026 ";
        String p = pass.strip();
        boolean ok = p.length() >= 8 && p.chars().anyMatch(Character::isDigit);
        System.out.println("password ok? " + ok + " (" + p.length() + " chars)");

        // ৩. SMS-এ একটি অক্ষর কতবার আছে
        String sms = "Apnar bKash e 500 tk ese geche";
        long count = sms.chars().filter(ch -> ch == 'e').count();
        System.out.println("'e' count: " + count);

        // ৪. বাংলা আর emoji: length() গোনে UTF-16 code unit
        String[] words = {"বাংলা", "ক্ষমা", "😀"};
        for (String w : words) {
            System.out.println(w + " length=" + w.length() + " codePoints=" + w.codePoints().count());
        }
    }
}

ফোন নম্বর: তিনটি replace শিকলের মতো চলেছে — প্রতিটি নতুন String দিয়েছে, পরেরটি সেটির ওপর কাজ করেছে। ফল 8801712345678, দৈর্ঘ্য ১৩, শুরু 8801 দিয়ে, তাই valid? true. একই কাজ এক লাইনে raw.replaceAll("[^0-9]", "") দিয়েও হয় (regex: “০-৯ ছাড়া সব”)। কিন্তু মনে রাখুন অংশ ৫: এই regex বাংলা অঙ্কও মুছে দেবে।

পাসওয়ার্ড: strip() আগে-পরের ফাঁকা বাদ দিয়ে dhaka2026 (৯ char) দিয়েছে। chars() প্রতিটি char-কে একটি সংখ্যা হিসেবে একটি stream-এ দেয়, আর anyMatch(Character::isDigit) জিজ্ঞেস করে “অন্তত একটি অঙ্ক আছে?” — true. Stream আর :: চিহ্নের পুরো গল্প পর্ব ৩৪–৩৫-এ; আজ শুধু পড়তে পারলেই চলবে। SMS: filter শুধু 'e' রাখে, count গোনে — ফল 5. খেয়াল করুন 'e' একক কোটে, অর্থাৎ char.

বাংলা ও emoji: বাংলা আর ক্ষমা দুটোরই length আর code point 5 — বাংলায় দুটি সংখ্যা সমান, কিন্তু চোখের অক্ষরের (৩ আর ২) সাথে মেলে না। 😀-এর length 2 কিন্তু code point 1 — surrogate pair. বাংলা আউটপুট এখানে ঠিকঠাক দেখাচ্ছে, কারণ runner UTF-8 ব্যবহার করে।

আগাম দেখা — format আর text block (পর্ব ০৯)

অনেকগুলো + দিয়ে লেখা সাজানো ঝামেলার। পরের পর্বে শিখবেন ছাঁচ দিয়ে সাজানো: String.format("%-8s|%5.1f", "Rahim", 3.456), বা Java 15 থেকে একই কাজ "%-8s|%5.1f".formatted("Rahim", 3.456) — ফল Rahim | 3.5. আর কয়েক লাইনের লেখা (যেমন একটি JSON বা SQL) \n ছাড়া লেখার জন্য আছে text block — তিনটি ডাবল কোট """ দিয়ে শুরু ও শেষ, Java 15 থেকে স্থায়ী। দুটোই শেষ পর্যন্ত একটি সাধারণ immutable String বানায়। বিস্তারিত পর্ব ০৯-এ।

পরীক্ষা ও ইন্টারভিউতে
  • String কেন immutable? — নিরাপদে শেয়ার আর string pool সম্ভব হয়, thread-safe হয়, hashCode একবার হিসাব করে রাখা যায়, আর HashMap-এর key হিসেবে ভরসাযোগ্য।
  • == আর equals-এর পার্থক্য? — == দেখে একই object কি না; equals দেখে লেখা সমান কি না। String মেলাতে সবসময় equals.
  • String s = new String("hi") কয়টি object তৈরি করে? — সাধারণত দুটি: pool-এ literal "hi" (আগে না থাকলে) আর heap-এ new-এর আলাদা object.
  • String, StringBuilder, StringBuffer-এর পার্থক্য? — String বদলায় না; StringBuilder বদলায় ও দ্রুত; StringBuffer বদলায় কিন্তু synchronized, তাই thread-safe ও একটু ধীর।
  • "a.b.c".split(".")-এর দৈর্ঘ্য কত? — 0, কারণ split regex নেয় আর . যেকোনো অক্ষরের সাথে মেলে; ঠিক রূপ split("\\.").

৮ · AI-এর যুগে — লেখা নিয়ে AI-এর কোড যাচাই

String-এর কাজ AI-কে দিলে কোড সাধারণত compile হয়, চলেও — ইংরেজি পরীক্ষার লেখায়। ভুলগুলো লুকিয়ে থাকে ঠিক সেই জায়গায় যা এই পর্বে দেখলেন: ঠিকানা বনাম লেখা, লুপের খরচ, regex আর বাংলা। এগুলো ছোট পরীক্ষায় ধরা পড়ে না, পড়ে আসল ব্যবহারকারীর হাতে।

AI-এর String কোড ইংরেজি “test” লেখায় চলে।
আপনি পরীক্ষা করুন বাংলা, ফাঁকা জায়গা, null আর বড় ইনপুট দিয়ে।

AI সাধারণত যে ভুলগুলো করে

AI যা লেখেকেন বিপজ্জনকআপনি যা করবেন
if (role == "admin")literal দিয়ে পরীক্ষায় চলে, ইনপুটে false"admin".equals(role)
বড় লুপে result += linequadratic খরচ — বড় ডেটায় প্রোগ্রাম আটকে যায়StringBuilder বা String.join / Collectors.joining
split("."), split("|"), replaceAll("$", …)regex চিহ্ন; ফল ফাঁকা বা ভুল"\\.", Pattern.quote(…), বা regex ছাড়া replace
name.length() > 10 দিয়ে “১০ অক্ষর”বাংলায় কার-চিহ্ন আর হসন্তও গোনা হয়; emoji দুবারনিয়মটি কী গুনবে লিখে দিন, বাংলা দিয়ে পরীক্ষা করুন
s.trim(); একা এক লাইনেফল হারিয়ে যায়, s বদলায় নাs = s.strip();
ইনপুটে সরাসরি parseIntফাঁকা জায়গা বা খালি ঘরে NumberFormatExceptionআগে strip(), isBlank() যাচাই, তারপর রূপান্তর

String-এর কাজ চাইলে এই কাঠামোয় প্রশ্ন করুন। শেষের শর্তগুলো AI-কে ঠিক সেই ফাঁদগুলো এড়াতে বাধ্য করে:

আমি Java 21 ব্যবহার করছি। একটি method লিখে দাও যা [কাজটি লেখো — যেমন: ব্যবহারকারীর দেওয়া নাম পরিষ্কার করে, আর ২০ অক্ষরের বেশি হলে ছোট করে দেখায়]। শর্ত: ১. String মেলাতে == নয়, equals বা equalsIgnoreCase ব্যবহার করবে; null এলে কী হবে, বলে দেবে। ২. লুপে লেখা জোড়া লাগাতে হলে StringBuilder বা String.join ব্যবহার করবে। ৩. split, replaceAll বা matches ব্যবহার করলে প্রতিটি regex-এর মানে এক লাইনে ব্যাখ্যা করবে, আর বিশেষ চিহ্ন escape করবে। ৪. ইনপুট বাংলা হতে পারে। length() দিয়ে "অক্ষর" গুনলে স্পষ্ট বলবে এটি UTF-16 code unit গোনে, দেখা অক্ষর নয়; বাংলা যুক্তাক্ষর বা কার-চিহ্ন যেন মাঝখান থেকে কেটে না যায়। ৫. শেষে ৫টি পরীক্ষার ইনপুট দাও: একটি বাংলা নাম ("মোহাম্মদ আবদুল্লাহ"), আগে-পরে ফাঁকা জায়গাসহ একটি, খালি লেখা, null, আর একটি emoji-সহ নাম — প্রতিটির প্রত্যাশিত আউটপুটসহ।

AI-এর String কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১Java-র নকশাকারেরা String-কে কেন immutable আর final (অর্থাৎ কেউ extend করতে পারবে না) বানালেন? বদলানো যায় এমন String কি বেশি সুবিধার হতো না?

সুবিধার মনে হলেও বদলানো যায় এমন String একটি বড় সিস্টেমে তিন দিক থেকে বিপদ ডেকে আনত — নিরাপত্তা, hashing আর মেমরি।

নিরাপত্তা। Java-র ভেতরে String সব জায়গায়: class-এর নাম, ফাইলের পথ, নেটওয়ার্কের ঠিকানা, ডেটাবেসের সংযোগ-তথ্য। ধরুন একটি কোড আগে যাচাই করল “এই ফাইলের পথটি অনুমোদিত কি না”, তারপর সেই পথ দিয়ে ফাইল খুলল। String বদলানো গেলে অন্য কোনো কোড (বা অন্য thread) ঠিক এই দুই মুহূর্তের মাঝখানে পথটি বদলে দিতে পারত — যাচাই হলো একটি পথের, খোলা হলো আরেকটি। এই ধরনের আক্রমণকে বলে time-of-check to time-of-use (TOCTOU). Java-র শুরুর দিকে ব্রাউজারে অচেনা কোড (applet) চালানোর পুরো নিরাপত্তা-ব্যবস্থা এই ভরসার ওপর দাঁড়িয়ে ছিল যে একবার যাচাই করা String আর বদলাবে না।

final কেন? String-কে কেউ extend করতে পারলে একটি “নকল String” subclass বানিয়ে ভেতরে বদলানোর ক্ষমতা ঢোকানো যেত, আর যে method String নেয় সে বুঝতেই পারত না। final নিশ্চিত করে যে String টাইপের প্রতিটি object আসল, immutable String.

Hashing. HashMap key-এর hashCode দিয়ে ঘর বাছে। key-এর লেখা পরে বদলালে সেটি ভুল ঘরে পড়ে থাকত — get আর কখনো খুঁজে পেত না। Immutability-র আরেকটি লাভ: String তার hashCode প্রথমবার হিসাব করে নিজের ভেতরে রেখে দেয়, পরের বার আর হিসাব লাগে না। Map-এ লাখো বার খোঁজায় এটি বড় সাশ্রয়।

মেমরি আর pool. string pool সম্ভবই হয়েছে immutability-র জন্য। একই "OK" হাজার জায়গায় শেয়ার করা যায় কেবল তখনই, যখন কেউ সেটি বদলাতে পারে না।

দাম হলো লেখা বারবার বদলানোর খরচ — যার জন্য StringBuilder আছে। এই ভাগাভাগি একটি সাধারণ নকশা-নীতি: যে জিনিস সবাই শেয়ার করে তা immutable, আর যে জিনিস একজন তৈরি করছে তা mutable — কাজ শেষে immutable-এ রূপান্তর। পর্ব ২৫-এর record-ও এই চিন্তা থেকে এসেছে।

মূল উপলব্ধি: যে জিনিস সারা সিস্টেম ভাগ করে নেয়, তাকে বদলানোর অধিকার কাউকে না দেওয়াই সবচেয়ে সস্তা নিরাপত্তা। Immutability নিরাপত্তা, দ্রুত hashing আর মেমরি-সাশ্রয় — তিনটিই একসাথে দেয়।

প্র ০২Java 9-এর “compact strings” (JEP 254) কী বদলাল? একটি ইংরেজি লেখা আর একটি বাংলা লেখা মেমরিতে কি একই জায়গা নেয়?

Java 8 পর্যন্ত প্রতিটি String-এর ভেতরে ছিল একটি char[] — প্রতিটি অক্ষরের জন্য ২ বাইট, অক্ষর যা-ই হোক। কিন্তু বাস্তবের সার্ভার অ্যাপ্লিকেশনে বেশিরভাগ String — JSON-এর key, URL, লগ মেসেজ, ডেটাবেসের কলামের নাম, ইমেইল ঠিকানা — সাধারণ ইংরেজি অক্ষর, যা ১ বাইটেই আঁটে। heap-এর বিশ্লেষণে দেখা যাচ্ছিল, String-এর ভেতরের এই array-গুলোই heap-এর একটি বড় অংশ নিচ্ছে, আর তার প্রায় অর্ধেক বাইট শূন্য।

Compact strings (JEP 254, Java 9) ভেতরের গঠন বদলে দিল। এখন String-এর ভেতরে থাকে একটি byte[] আর একটি ছোট চিহ্ন, যার নাম coder. String তৈরির সময় JVM দেখে নেয় সব অক্ষর Latin-1 (প্রথম ২৫৬টি নম্বর)-এ আঁটে কি না। আঁটলে প্রতি অক্ষরে ১ বাইট (coder = LATIN1); একটিও না আঁটলে আগের মতো প্রতি অক্ষরে ২ বাইট (coder = UTF16).

তাহলে বাংলার কী হলো? বাংলা অক্ষরের নম্বর U+0980 থেকে U+09FF — Latin-1-এর বাইরে। তাই "বাংলা" আগের মতোই প্রতি অক্ষরে ২ বাইট নেয়, আর "Dhaka" এখন ১ বাইট। একটি বাংলা অক্ষর মেশালেই পুরো String UTF16 রূপে চলে যায়। বাংলা অ্যাপে তাই সাশ্রয় কম, তবে লোকসানও নেই — আর অ্যাপের ভেতরের অনেক String (key, URL, লগ) তো ইংরেজিই।

সবচেয়ে গুরুত্বপূর্ণ দিক: প্রোগ্রামারের কোডে একটি লাইনও বদলাতে হয়নি। length(), charAt() সব আগের মতোই UTF-16 code unit-এর হিসাবে উত্তর দেয়; ভেতরের রূপ বদলেছে, বাইরের চুক্তি নয়। এটা সম্ভব হয়েছে কারণ String-এর ভেতরের array কোনোদিন বাইরে দেখানো হয়নি — encapsulation (পর্ব ১৭)-এর একটি চমৎকার উদাহরণ। পুরোনো আচরণে ফিরতে -XX:-CompactStrings নামের JVM option রাখা আছে।

এর সাথে একটি সূক্ষ্ম লাভও আছে: ছোট array মানে CPU cache-এ বেশি ডেটা আঁটে, আর garbage collector-কে কম মেমরি ঘাঁটতে হয়। ফলে অনেক ক্ষেত্রে শুধু মেমরিই নয়, গতিও বাড়ে।

মূল উপলব্ধি: ভালো abstraction ভেতরের রূপ বদলানোর স্বাধীনতা দেয়। String-এর ভেতরটা লুকানো ছিল বলেই কোটি কোটি লাইনের কোড না ছুঁয়ে Java মেমরির ব্যবহার কমাতে পেরেছে।

প্র ০৩Java 7-এ string pool কেন PermGen থেকে সরিয়ে সাধারণ heap-এ আনা হলো? intern() ব্যবহারে এর কী প্রভাব পড়ল?

Java 6 পর্যন্ত JVM-এর (HotSpot) মেমরিতে একটি আলাদা এলাকা ছিল — PermGen (permanent generation). সেখানে থাকত class-এর তথ্য, method-এর কোড, আর interned String — অর্থাৎ string pool. সমস্যা হলো PermGen-এর মাপ ছিল নির্দিষ্ট আর তুলনামূলক ছোট, প্রোগ্রাম চালুর সময় ঠিক হয়ে যেত, আর heap-এর সাথে প্রয়োজনমতো বাড়ত না।

ফলে কোনো প্রোগ্রাম অনেক লেখা intern() করলে — যেমন একটি XML parser প্রতিটি tag-এর নাম intern করছে, বা একটি সার্ভার প্রতিটি অনুরোধের কিছু অংশ intern করছে — PermGen ভরে যেত, আর প্রোগ্রাম থেমে যেত বিখ্যাত error দিয়ে: java.lang.OutOfMemoryError: PermGen space. heap-এ তখন হয়তো প্রচুর জায়গা খালি, তবু লাভ নেই। অনেক দল তাই নিয়ম করেছিল “intern() ব্যবহার কোরো না”।

Java 7-এ interned String-গুলো সাধারণ heap-এ সরানো হয়। এর তিনটি ফল: (১) pool এখন heap-এর মোট মাপ পর্যন্ত বাড়তে পারে; (২) কেউ আর ব্যবহার না করা interned String-ও সাধারণ garbage collection-এ মুছে যেতে পারে; (৩) PermGen-এর আলাদা মাপ ঠিক করার ঝামেলা কমে। পরের ধাপে Java 8 PermGen পুরোপুরি তুলে দেয় (JEP 122), আর class-এর তথ্য রাখার জন্য আনে Metaspace, যা native মেমরিতে থাকে ও প্রয়োজনে বাড়ে। এই গল্পের বাকি অংশ পর্ব ৪৪-এ।

তবু intern() কি এখন ঢালাওভাবে ব্যবহার করা যায়? না। pool একটি hash table (শুরুর মাপ JVM option -XX:StringTableSize দিয়ে ঠিক করা যায়); কোটি কোটি লেখা intern করলে table বড় করতে আর খুঁজতে বাড়তি খরচ হয়। আর intern() নিজেও একটি খোঁজার খরচ। তাই intern() একটি বিশেষ যন্ত্র — যখন লাখো বার পুনরাবৃত্তি হওয়া অল্প কয়েকটি লেখা (যেমন দেশের নাম, অবস্থার নাম) মেমরিতে জমছে এবং মেপে দেখা গেছে এটি সমস্যা। সাধারণ কোডে প্রয়োজন নেই, আর তুলনার জন্য তো একেবারেই নয় — তার জন্য equals.

মূল উপলব্ধি: আলাদা নির্দিষ্ট মাপের মেমরি এলাকা সহজ দেখায়, কিন্তু বাস্তবের অনিশ্চিত চাহিদায় ভেঙে পড়ে। JVM-এর বিবর্তন বারবার দেখিয়েছে — যে মেমরি বাড়তে-কমতে পারে আর একই garbage collector যার দেখাশোনা করে, তা বেশি টেকসই।

প্র ০৪length() কেন দেখা অক্ষর নয়, UTF-16 code unit গোনে? Java কি এটা এখন বদলে দিতে পারে না?

উত্তরটি পুরোপুরি ইতিহাসে। ১৯৯০-এর দশকের শুরুতে Unicode-এর প্রথম পরিকল্পনা ছিল একটি সরল প্রতিশ্রুতি: পৃথিবীর সব লিপির সব অক্ষর ১৬ বিটে, অর্থাৎ ৬৫,৫৩৬টি নম্বরে এঁটে যাবে। একে বলা হতো UCS-2 ধারণা — প্রতিটি অক্ষর ঠিক ২ বাইট। Java-র নকশা হয় ঠিক এই সময়ে। তাই নকশাকারেরা char-কে ১৬ বিটের করলেন, ভাবলেন “একটি char = একটি অক্ষর” চিরকাল সত্য থাকবে। সেই সময়ের জন্য এটি দূরদর্শী সিদ্ধান্ত ছিল — C-র ৮ বিটের char যেখানে শুধু ইংরেজি বুঝত, Java প্রথম দিন থেকেই বাংলা, চীনা, আরবি বুঝত।

কিন্তু ১৯৯৬ সালে Unicode 2.0 মেনে নেয় যে ৬৫,৫৩৬ যথেষ্ট নয় — চীনা-জাপানি-কোরিয়ান লিপির হাজারো অক্ষর, ঐতিহাসিক লিপি, গাণিতিক চিহ্ন বাকি। নম্বরের সীমা বাড়িয়ে প্রায় ১১ লাখ করা হয়, আর ১৬ বিটের জগতে বড় নম্বর লেখার উপায় হিসেবে আসে surrogate pair: দুটি বিশেষ ১৬-বিটের টুকরো মিলে একটি অক্ষর। এভাবে UCS-2 হয়ে গেল UTF-16. পরে emoji এসে বসল ঠিক এই বড় নম্বরের এলাকায়।

Java তখন একটি কঠিন অবস্থায়। কোটি কোটি লাইন কোড ধরে নিয়েছে যে length() মানে char-এর সংখ্যা, charAt(i) মানে i নম্বর char. অর্থ বদলালে সেই সব কোড নিঃশব্দে ভুল ফল দিত — compile error-ও দিত না। Java-র সবচেয়ে বড় প্রতিশ্রুতি backward compatibility, তাই সিদ্ধান্ত হলো: পুরোনো method-এর অর্থ থাকবে, নতুন method যোগ হবে। ২০০৪ সালে Java 5-এ (JSR 204) codePointAt, codePointCount, Character.isLetter(int) ইত্যাদি আসে; Java 8-এ আসে codePoints() stream.

আর তৃতীয় স্তর — দেখা অক্ষর — কোনো সরল নম্বর দিয়েই ধরা যায় না। বাংলার “ক্ষ” তিনটি code point; একটি পরিবারের emoji কয়েকটি code point জুড়ে তৈরি। কোনগুলো মিলে একটি দেখা অক্ষর, তার নিয়ম Unicode-এর আলাদা একটি দলিলে (UAX #29) লেখা, যা সংস্করণে সংস্করণে বদলায়। তাই প্রায় কোনো ভাষাই length-কে দেখা অক্ষরের সংখ্যা বানায় না; সেটি আলাদা library-র কাজ।

মূল উপলব্ধি: “অক্ষর” শব্দটির অন্তত তিনটি অর্থ — মেমরির টুকরো, Unicode নম্বর আর চোখের অক্ষর। কোনো কোড লেখার আগে ঠিক করুন আপনি কোনটি গুনছেন; আর জেনে রাখুন, একবার প্রকাশিত API-র অর্থ বদলানো প্রায় অসম্ভব।

প্র ০৫G1 garbage collector-এর “string deduplication” (JEP 192) কী করে? এটি intern() থেকে কোথায় আলাদা?

বড় Java অ্যাপ্লিকেশনের heap বিশ্লেষণ করলে প্রায়ই একটি অদ্ভুত ছবি দেখা যায়: হাজার হাজার আলাদা String object, যাদের লেখা হুবহু এক। কারণটি সহজ — ডেটাবেস থেকে আসা প্রতিটি সারিতে "Dhaka", প্রতিটি JSON অনুরোধে "status", প্রতিটি লগে একই মেসেজ। এগুলো চলার সময়ে তৈরি, তাই pool-এ যায় না; প্রত্যেকটি নিজের আলাদা অক্ষরের array নিয়ে বসে থাকে। JEP 192-এর প্রস্তাবনায় বলা হয়েছিল, অনেক বড় অ্যাপ্লিকেশনে heap-এর উল্লেখযোগ্য অংশ এমন String-এ যায়, আর তার বড় অংশ নকল।

String deduplication এই নকলগুলো খুঁজে বের করে G1 GC-এর পেছনের কাজের অংশ হিসেবে। এটি এসেছে JDK 8u20-তে, আর চালু করতে হয় -XX:+UseStringDeduplication দিয়ে। G1 যখন object-গুলো ঘাঁটে, তখন String পেলে তার লেখার একটি হ্যাশ নিয়ে একটি তালিকায় খোঁজে। একই লেখার আরেকটি String আগে পাওয়া গেলে, নতুনটির ভেতরের array-কে পুরোনোটির array দেখিয়ে দেয়; অব্যবহৃত array পরে মুছে যায়।

intern() থেকে মূল পার্থক্য তিনটি। (১) কী শেয়ার হয়: intern() পুরো String object-টিই এক করে, তাই intern-এর পরে == true দেয়। Deduplication শুধু ভেতরের array শেয়ার করে — String object আলাদাই থাকে, তাই == আগের মতোই false; প্রোগ্রামের আচরণ একটুও বদলায় না। (২) কে করে: intern() আপনি কোডে লেখেন; deduplication একটি JVM option, কোডে কিছু লিখতে হয় না। (৩) কখন: intern() সঙ্গে সঙ্গে; deduplication পরে, পেছনে, সাধারণত শুধু সেই String-এ যেগুলো কয়েকটি garbage collection পার করে টিকে আছে — ক্ষণস্থায়ী String-এর জন্য এই খরচ করা হয় না।

লক্ষ করুন, এই কৌশলও immutability ছাড়া অসম্ভব। দুটি String একটি array ভাগ করে নিচ্ছে — কেউ বদলাতে পারলে অন্যজনের লেখাও বদলে যেত। পরে অন্য কয়েকটি collector-এও এই সুবিধা যোগ হয়েছে। তবে এটি বিনামূল্যে নয় — হ্যাশ হিসাব আর তালিকা রাখতে কিছু CPU খরচ হয়, তাই মেপে দেখে চালু করাই নিয়ম।

মূল উপলব্ধি: Immutability একবার নিশ্চিত হলে runtime নিজেই নিরাপদে অনেক অপ্টিমাইজেশন করতে পারে — pool, hash cache, compact strings, deduplication — প্রোগ্রামারের কোড না ছুঁয়ে।

অনুশীলন

বেশিরভাগ কাজে নতুন কোড লিখতে হবে না — ওপরের কোড বদলে দেখা আর ভাবাই আসল কাজ।

  1. আউটপুট অনুমান করুন: না চালিয়ে বলুন — String s = " Dhaka "; তারপর s.strip(); s.toUpperCase(); তারপর System.out.println("[" + s + "]"); কী ছাপবে? তারপর এক লাইন বদলে [DHAKA] ছাপান।

    ছাপবে [ Dhaka ] — আগে-পরের ফাঁকা জায়গা আর ছোট হাতের অক্ষর সব আগের মতো। দুটি method-ই নতুন String ফেরত দিয়েছে, কিন্তু কেউ সেগুলো ধরেনি, আর s-এর object বদলানো সম্ভব নয়। ঠিক রূপ: s = s.strip().toUpperCase(); — প্রথম method-এর নতুন String-এর ওপর দ্বিতীয়টি কাজ করে, আর শেষ ফলটি s-এ রাখা হয়। এখন ছাপবে [DHAKA].

  2. == ফাঁদ নিজে ঘটান: অংশ ২-এর চালানো যায় এমন কোডে String part = "Dha";-এর আগে final লিখে চালান। a == d-এর ফল কী হলো, আর কেন?

    এবার a == d হবে true (Wandbox-এ যাচাই করা)। final String part = "Dha"; একটি compile-time constant — compiler জানে এর মান কখনো বদলাবে না। তাই সে part + "ka"-কে compile-এর সময়েই "Dhaka" বানিয়ে ফেলে, আর সেটি একটি literal হয়ে pool-এর #1 object-ই পায়। একই কারণে "Dha" + "ka" == "Dhaka"-ও true. শিক্ষা: ==-এর ফল নির্ভর করে compiler কী অপ্টিমাইজ করল তার ওপর — একটি final শব্দ যোগ বা বাদ দিলেই ফল উল্টে যায়। এমন ভঙ্গুর জিনিসের ওপর কখনো ভরসা নয়; সবসময় equals.

  3. split-এর রহস্য: একজন বন্ধুর কোড "192.168.0.1".split(".") দিয়ে IP ঠিকানার চারটি অংশ বের করতে চাইছে, কিন্তু array-র দৈর্ঘ্য আসছে 0. কেন? দুটি আলাদা সমাধান দিন।

    split তার লেখাটিকে regex হিসেবে পড়ে, আর regex-এ . মানে “যেকোনো অক্ষর”। তাই প্রতিটি অক্ষরই বিভাজক হয়ে গেছে, সব টুকরো ফাঁকা, আর শেষের ফাঁকা টুকরোগুলো বাদ পড়ায় কিছুই রইল না। সমাধান ১: split("\\.") — regex-এ \. মানে আসল ডট, আর Java String-এ \ লিখতে হয় \\. সমাধান ২: split(Pattern.quote(".")) — লেখাটিকে হুবহু অর্থে নিতে বলে (java.util.regex.Pattern import করতে হবে)। দুটোতেই ফল [192, 168, 0, 1], দৈর্ঘ্য 4.

  4. বাংলা গুনুন: অংশ ৭-এর কোডে words array-তে "স্ক্র", "কি" আর "🇧🇩" (বাংলাদেশের পতাকা) যোগ করে চালান। প্রতিটির length আর codePoints দেখে ব্যাখ্যা করুন, কেন পতাকার length 4.

    স্ক্র: স, ্, ক, ্, র — length 5, codePoints 5, অথচ চোখে একটি যুক্তাক্ষর। কি: ক আর ি — length 2, codePoints 2; ি-কার আগে দেখা গেলেও মেমরিতে ক-এর পরে থাকে। 🇧🇩: পতাকা আসলে দুটি “regional indicator” অক্ষর (B আর D-এর বিশেষ রূপ), প্রত্যেকটির নম্বর ৬৫,৫৩৬-এর বেশি, তাই প্রত্যেকটি একটি surrogate pair — codePoints 2, length 4. চোখে একটি পতাকা, Java-র হিসাবে চারটি char. তাই “নাম ১০ অক্ষরের বেশি নয়” ধরনের নিয়ম length() দিয়ে লিখলে বাংলা আর emoji-তে মানুষ অবাক হবেন।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি নামের তালিকা থেকে কমা দিয়ে আলাদা করা একটি লাইন বানানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. StringBuilder sb = new StringBuilder(); — একটি খালি বাক্স
  2. লুপ শুরু — তালিকার প্রতিটি নামের জন্য
  3. sb খালি না হলে আগে একটি কমা append
  4. নামটি strip() করে append
  5. লুপ শেষে sb.toString() — একটি immutable String

String s = "cha"; s.toUpperCase(); System.out.println(s); — কী ছাপা হবে?

  • CHA
  • cha
  • compile error
  • NullPointerException

String a = "Dhaka"; String part = "Dha"; String d = part + "ka"; — এখানে a == d আর a.equals(d)-এর ফল কী?

  • true, true
  • false, false
  • false, true
  • true, false

"Bangladesh".substring(2, 6) কী ফেরত দেয়?

  • "ngla"
  • "nglad"
  • "angl"
  • "nglade"

Java-তে "a.b.c".split(".").length-এর মান কত?

  • 3
  • 1
  • PatternSyntaxException
  • 0

একটি লুপে ১ লাখ লাইন জোড়া লাগিয়ে একটি বড় রিপোর্ট বানাতে হবে, শুধু একটি thread থেকে। কোনটি সবচেয়ে উপযুক্ত?

  • লুপের ভেতরে String-এ += দিয়ে জোড়া
  • StringBuffer
  • StringBuilder
  • প্রতিটি লাইন intern() করে জোড়া

Java-তে "বাংলা".length() কত, আর কেন?

  • 3, কারণ চোখে তিনটি অক্ষর
  • 5, কারণ কার-চিহ্নসহ পাঁচটি UTF-16 code unit
  • 10, কারণ প্রতিটি বাংলা অক্ষর দুটি char
  • 15, কারণ UTF-8-এ প্রতিটি অক্ষর ৩ বাইট

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. String একটি immutable object: “বদলানো” method নতুন String দেয় — ফলটি variable-এ রাখুন (s = s.strip();).
  2. literal-গুলো string pool-এ একটিই object; লেখা মেলাতে সবসময় equals / equalsIgnoreCase, কখনো == নয়; null থাকলে "literal".equals(x).
  3. substring(begin, end)-এ শেষ index বাদ; split আর replaceAll regex নেয় — "\\."; নতুন কোডে strip আর isBlank.
  4. এক লাইনের জোড়ায় + ঠিক আছে; লুপে StringBuilder. StringBuffer একই, কিন্তু synchronized আর পুরোনো।
  5. length() গোনে UTF-16 code unit — বাংলায় কার-চিহ্ন ও হসন্তও গোনা হয়, emoji দুবার; parseInt বাংলা অঙ্ক পড়ে, regex-এর \d পড়ে না।

পরের পর্বে: এতক্ষণ সব লেখা ছিল কোডের ভেতরে। এবার ব্যবহারকারীর কাছ থেকে নেওয়ার পালা — Scanner দিয়ে কীবোর্ডের ইনপুট পড়া, printf আর formatted দিয়ে সুন্দর করে সাজানো আউটপুট, আর text block দিয়ে কয়েক লাইনের লেখা।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান