Повечето въпроси, свързани с Java, попадат в няколко категории и след като разберете основите за всеки един от тях, ще можете да направите интервюто за Java. Ще обсъдим тези категории в тази статия

Това няма да бъде поръчано по никакъв начин. Не можете да кажете какво ще ви попитат на интервюто. Най-добрият начин да се подготвите е да имате солидна основа, по този начин, ако ви попитат нещо, което не знаете (или сте забравили), ще можете да си проправите път към отговора логично и с малко помощ от интервюиращия (ако те са в настроение). Много компании ще ви харесат повече, ако измислите отговор на нещо, което не знаете пред тях. Това демонстрира вашия потенциал и способност да се адаптирате към непознати проблеми.

Няма да си правим труда да изброяваме всички възможни въпроси, по-скоро ще обсъдим концепции, които биха могли да вдъхновят интервюиращите да ви попитат нещо.

Езиково ядро ​​на Java

Тук ще отиват всички въпроси, свързани с това как работи самият език. Какво е толкова специалното на Java и кои са основните предимства на езика?

Java е междуплатформена

Това е така, защото за разлика от машинните езици като C, Java се компилира в java байт код. Това е специален двоичен формат, който разбира само Java runtime (JRE). По същество това е набор от инструкции и всяка инструкция се превежда в инструкция на ОС (машина).

Ние като разработчици пишем само изходния код (‘.java’ файлове). Компилаторът (включен в комплекта за разработка на Java — JDK) взема тези текстови файлове и ги преобразува в универсалния байткод на Java („.class“ файлове). След това имаме различно време за изпълнение на Java (JRE) за всички устройства и версии на устройства, поддържани от Java. Клиентите на нашите приложения имат подходящ JRE инсталиран на своите машини и могат да изпълняват нашия байт код. Недостатъкът на това е, че имате нужда от JRE, за да стартирате всяко Java приложение.

Събиране на боклук

В Java ние не управляваме паметта си. В езиците от по-ниско ниво ключовата дума new изпълнява системно извикване, което разпределя част от паметта в RAM. Java управлява RAM вместо вас, като разпределя големи части от паметта, когато приложението стартира. Ключовата дума new в Java не прави системни извиквания, тя просто взема парчета от вече разпределената памет, което прави операцията много по-евтина.

Това е добре, но означава, че нямаме средства за освобождаване на паметта, която вече не използваме. Това обаче вече се обработва от Garbage collector, който проследява всички обекти, които създаваме, и ги изтрива от RAM, след като станат подходящи за събиране

За да разберем процеса GC малко по-добре, трябва да сме наясно с два раздела на Java RAM.

Пространство в стека

Всички препратки към обекти се съхраняват в стека. Обърнете внимание на думата „препратка“, която е различна от указател и адрес. Това е специално нещо за Java, известно още като „манипулатор на обект“. Това пространство в стека е много малко, 512K по подразбиране, така че не можете да съхранявате много неща там

Групово пространство

Хийп пространството е динамично и на практика неограничено. Вие сте ограничени само от хардуерните ресурси. Тук живеят всички по-големи обекти, необходими на вашето приложение. Heap обектите са достъпни чрез препратки от пространството на стека, не можете просто да инспектирате клетките на паметта, както бихте направили с C++ указатели.

За да илюстрирате това, разгледайте следното

На тази линия се случват две неща. Препратката, наречена „obj“, се създава в стека. Типовете означават, че тази препратка може да сочи само към обекти от тип „Object“. След това създаваме нов тип обект в купчината и използвайки оператора „=“, насочваме препратката към него.

Отговаря на условията за събиране

В един момент този обект ще стане остарял и трябва да го изтрием или в нашия случай GC трябва да го направи. Правилото е „Ако даден обект вече не е достъпен от стека, той отговаря на условията за събиране“. Препратките в стека се изтриват автоматично, когато излязат от обхвата (когато обхватът на метода приключи). Когато това се случи, обектът на купчината вече няма да бъде достъпен и в крайна сметка ще бъде изтрит.

GC не се случва веднага, това е много сложен процес, който проследява много неща като продължителност на живота на обекта и честота на разпределение. Това ще се случва на интервали в зависимост от производителността на приложението. Това е доста напреднала тема.

Сега помислете за този много често задаван въпрос, имате следния код

По същество имате два обекта, сочещи един към друг. Въпросът е кога ще бъдат годни за събиране и след като сочат един към друг, това не означава ли, че винаги са достъпни? Това ще ги направи ли никога не отговарящи на условията за събиране и ще доведе до изтичане на памет?

Сега трябва да ви е очевидно, че няма да имаме препратка от стека към тези обекти и GC няма да се интересува дали те сочат един към друг, тъй като и двата са в купчината. GC се интересува само от препратките към стека.

Компилация точно навреме (JIT)

Езиците от ниско ниво се компилират веднъж и създават изпълним файл. Този изпълним файл е неизменен и веднъж създаден, той не променя поведението си. От друга страна, Java изпълнява JVM, който се захранва с байт код. Това е жива система, което й позволява да прави определени корекции, докато програмата работи. Той се адаптира към начина на използване на програмата. Например, определен клон на кода се изпълнява много често. JIT ще кешира резултата и няма да го изчислява всеки път. Това е много мощно и прави Java по-бърза от езиците на ниско ниво за дълго работещи приложения като сървъри. Това е от съществено значение за производителността и е една от основните причини Java да е толкова популярна.

Най-общо казано, имате два вида оптимизации на компилиран код.

Оптимизации на ниво код — това включва неща като вграждане на функции, елиминиране на цикъл/разклонение и предварителни изчисления на променливи

Например int x = 1000 * 10 ще бъде заменено от int x = 10000 по време на компилация

Оптимизации по време на изпълнение — те включват споменатите по-горе кешове за резултати, но дори можем да имаме части от кода, заменени с естествен код, специфичен за платформата, на която работим.

Java извършва и двете оптимизации и се обработва от JIT компилатора.

Важни структури от данни

Имаме много вградени структури, няма нужда да ги познавате всички, но трябва да сте наясно с четирите основни типа.

Масив

Масивите са масивите в стил c, с които сме запознати. Елементите са последователно подредени в паметта.

Сложности

  • Insert е Log(N) — защото най-общо казано, трябва да създадем нов масив с нововмъкнатия елемент вътре
  • Изтриването е Log(N) — подобно на вмъкването
  • Get е Log(1) – имаме директен достъп до всички елементи по индекс, защото знаем точно къде се намират в паметта
  • Актуализацията е Log(1) — знаем точно къде е елементът
  • Съдържа е Log(N) — трябва да итерираме елементите

Без значение от броя на елементите в масива, изчислението за намиране на елемента в масива е еднократно умножение и събиране.

Масивите са добри, когато не се променят. Те са структурата с най-ефективно търсене на елементи.

Списък (свързан)

За разлика от масивите, списъците не са последователни в паметта. За да получите елемент, трябва да знаете предишния. Елементите образуват верига от препратки.

Сложности

  • Insert is Log(N) — трябва да намерим предишния елемент и да го насочим към новия. Вмъкването в началото обаче е Log(1).
  • Изтриването е Log(N) — трябва да намерим предишния елемент и да го насочим към следващия
  • Update/Get са Log(N) — трябва да намерим елементите
  • Съдържа е Log(N)— трябва да итерираме елементите

Списъкът звучи ужасно като сложност (и е така). Единственото скрито предимство, което има, е че използва памет, равна на броя на елементите. За разлика от масивите, не е необходимо да се копира за някои операции. Свързаните списъци трябва да се използват само когато паметта е проблем.

Списък с масиви

Най-широко използваната реализация на списък в Java. Комбинира масиви и списъци. Вътрешната реализация е масив с фиксиран размер с начален размер. Той реализира интерфейса List. Когато се вмъкне нов елемент, той просто заема елемента във вътрешния масив. Когато масивът стане твърде малък, той удвоява размера си, когато стане твърде голям, се свива. Това е саморегулираща се структура от данни

Сложности

  • Вмъкването се амортизира Log(1) — настройката на елемент на масив при индекс е Log(1), но понякога може да се наложи да увеличим размера на масива, което го прави Log(N) в най-лошия случай .
  • Изтриването се амортизира Log(1) — подобно на вмъкването, но може да решим да го свием, за да освободим малко памет
  • Get/Update са едновременно Log(1) — имаме API на списъка (интерфейс), но работим върху масив, който има Log(1) get an update
  • Съдържа Log(N)— трябва да итерираме елементите

Използвайте ArrayList всеки път, когато имате нужда от списък. Можем да посочим първоначалния капацитет, който определя размера на вътрешния масив. По този начин можем да го оптимизираме за конкретния случай на употреба.

Карта

Map (HashMap) е най-използваната структура от данни, защото предоставя Log(1) всичко в най-добрия случай. Хеш картите работят подобно на ArrayList. Те имат вътрешен масив, който им дава Log(1) четене/запис на неговите елементи. Той обаче не работи с индекси, а с хеш кодове. Данните в тях образуват уникални двойки ключ-стойност.

Хеш кодът е число, получено от хеш функции въз основа на стойността на елемента. Хеш функциите са сложни и много рядко ги пишете сами, по-скоро можете да използвате Objects.hash(..). Хеш функцията отговаря за равномерното разпределение на елементите във вътрешния масив на картата.

Когато вмъкваме нещо в картата, трябва да кажем неговия ключ. Картата ще хешира ключа и въз основа на хеш стойността ще избере индекса в масива. В крайна сметка ще получите два ключа, които ще попаднат в един и същ индекс. Това се нарича сблъсък и има два начина за справяне с него

Верижносткогато два елемента се окажат на един и същи индекс, ги поставя в свързан списък

Проучванекогато индексът вече е зает, запазете елемента на следващото свободно място. Това усложнява логиката на вътрешния масив, но премахва необходимостта от създаване на списъци. Това е начинът, по който HashMap в Java го прави.

Както можете да видите, картите силно разчитат на хеш функцията. Ако не разпредели елемента равномерно, производителността на картата ще се влоши. Екстремен пример би бил, ако хеш функцията винаги връща 1, поставяйки всички елементи в първия индекс, по този начин картата ще се представи толкова лошо, колкото Linked List.

Друг много важен аспект е „равно“. Ако искате даден обект да бъде съхранен в карта, трябва да приложите хеш и равенства. Equals се използва за намиране на елемента, когато има сблъсък.

Сложности, където K е средната честота на сблъсък (ако K е 5, всички индекси в картата ще имат 5 елемента за тях, ако приемем, че са равномерно разпределени от хеш функцията)

  • Вмъкването е Log(1)— хеширането на ключа е Log(1) и след това вмъкването му в началото на свързания списък също е Log(1)
  • Изтриването е Log(K)— Log(1) за намиране на ключа и след това K за намиране на конкретния елемент
  • Get/Update е Log(K) — Log(1) за намиране на ключа и след това трябва да намерим конкретния елемент
  • Съдържа е Log(K)— Намирането на елемента е Log(1), но ако е имало сблъсъци, трябва да намерим конкретния, като използваме равенства

Не се заблуждавайте от „К“. K обикновено е много малък, толкова малък, че ще работи като Log(1) и хората обикновено го свързват с константа. Картите са много бързи и ако вашите данни могат да образуват двойки ключ-стойност (което е много често), изберете карта, трудно е да сбъркате с нея.

Комплект

Комплектите съществуват с единствената цел да направят съдържанието супер ефективно. HashSets са най-често използваните и те отново разчитат на хеш функцията, за да работят правилно.

HashSet използва HashMap вътрешно, за да провери дали елементите съществуват или не. Няма получаване или актуализация.

Сложности

  • Вмъкване/Изтриване са Log(1) — предоставя се от вътрешната карта
  • Contains is Log(1)— той просто прави търсене на карта

Използвайте набори, когато трябва да проверите за съдържания и вашите данни не образуват двойки ключ-стойност.

Основни понятия на Java

Няколко неща, които много често изскачат при въпроси за интервю

Константи

Как се дефинира константа в Java?

static final int DAYS_OF_WEEK = 7;

И статични, и окончателни са необходими, за да се счита една променлива за константа. Final гарантира, че препратката не може да бъде преназначена, а static я прави глобална. Ако пропуснем ключовата дума static, всеки клас ще има свое копие на променливата и тя вече няма да бъде глобално уникална.

Интерфейси

Интерфейсите определят поведение, договор или API. Обикновено ще бъдете помолени да правите разлика между абстрактен клас и интерфейс и може да започнете да изброявате разликите, не е нужно да правите това. Това, което хората искат да чуят, е къде са използвани.

Интерфейсите определят как други пакети или приложения ще взаимодействат с вашия код. Пакетите трябва да показват само интерфейси, а външните потребители трябва да зависят само от интерфейси. Това върви добре в полза на SOLID и прави нещата много по-прости, ако се замислите.

Абстрактните класове определят базовата логика. Те никога не трябва да се излагат и се използват най-вече за определяне на основно поведение. Трябва да избягвате използването на абстрактни класове. Изграждането на сложни наследствени дървета е това, което дава на Java лошото име. Винаги предпочитайте композицията пред наследяването, ако следвате това правило, рядко ще имате нужда от абстрактни класове.

Можете да приложите много интерфейси, които могат да симулират множествено наследяване. Отново стара концепция, която прави нещата по-сложни, отколкото трябва да бъдат, но просто знайте, че е възможно. С абстрактни класове можете да наследите само един, което прави използването им още по-рядко.

Java примитивни обвивки

Защо са ни нужни? Това е грешка в езика. Java колекциите работят с генерични типове и не поддържат примитивни типове. Ако искаме да имаме списък с цели числа, не можем да кажем

List<int> ints = new ArrayList<>();

Трябва да използваме обвивката

List<Integer> ints = new ArrayList<>();

Хубавото е, че Oracle вече работи върху разрешаването на използването на примитиви в колекции, така че скоро (да се надяваме) те ще се превърнат в остаряло наследство. Дотогава обаче трябва да внимаваме за виновниците за тяхното представяне

Автоматично опаковане/разопаковане

Autoboxing е, когато присвоим примитивен тип на обвивка

Integer i = 5;

Autoboxing е, когато присвояваме обвивка на примитивен тип

int i =  Integer.valueOf(5);

Това е много скъпо и изисква много памет и трябва да се избягва. Помислете за следното

Вторият е 6 пъти по-бавен от първия и това се дължи на автоматичното разопаковане, което се случва на ред 3.

Пулове от низове и неизменни обекти

Струните са скъпи предмети. Те също се използват много често. Ето защо в Java те са обединени. Всеки път, когато трябва да се създаде низ, JVM първо проверява дали вече не е в набора от низове. Какво означава това? Разгледайте следния код

Операторът „==“ сравнява адресите в паметта. Ако е вярно, това означава, че сравняваме един и същ обект в паметта. Това означава, че „one1“ и „one2“ сочат към един и същ обект „one“, който е бил в пула от низове. „one3“ от друга страна сочи към съвсем нов обект и не е същото като „one1“, но те са равни.

Освен това низовете са неизменни. Неизменните обекти са такива обекти, чиято стойност никога не се променя веднъж създадена. Това е много мощно свойство, особено за многопоточност, където вече не трябва да се притеснявате дали стойността, с която работите, се е променила по някакъв начин.

Изключения

Имаме три вида изключения в Java

Изключения по време на компилиране (маркирано)

Потребителите са принудени да ги хващат по време на компилиране.

IOException е изключение по време на компилиране и потребителите са принудени да се справят с тях. Те трябва да се използват, когато можем да се възстановим от ситуацията, но това не се спазва стриктно навсякъде.

Изключения (непроверени) по време на изпълнение

Тези изключения не е необходимо да се улавят веднага. Обикновено имате глобален манипулатор на изключения, който улавя всички изключения по време на изпълнение по централизиран начин.

Както можете да видите, освен ако не потърсите изходния код на saveUser, няма начин да разберете, че е хвърлено изключение IllegalArgumentException. Най-общо казано, изключенията по време на изпълнение се използват, когато не можем да възстановим. Например предоставени от потребителя лоши данни и трябва да ги промените, преди да опитате отново.

Грешки

Грешките са изключения на системно ниво. Те се повдигат, когато например паметта свърши. OutOfMemoryError, когато пространството в стека свърши, и StackOverflowError, когато пространството в стека свърши. Много е лесно да причините тези грешки

Този код избутва int масиви в списъка в много дълъг цикъл. Масивите като всички обекти живеят в купчината, така че в крайна сметка го запълваме и получаваме OOE.

Запълването на стековото пространство е още по-лесно

Следите на стека на функциите се съхраняват в паметта на стека. Ако създадем бездънна рекурсия, получаваме StackOverflowError.

Грешки могат да възникнат и в други сценарии. Всички вътрешни сривове на JVM водят до грешки на VM, но в ежедневната си работа ще виждате най-вече грешки, свързани с паметта.

Многопоточност

Java има страхотна многонишкова поддръжка.

Нишка

Класът Thread ни дава достъп до нишките на OS. Повечето операции в нишката водят до системни извиквания. Нишките на Java се нанасят едно към едно към нишките на ОС. Нишките на ОС ни позволяват да изпълняваме код паралелно, като използваме множество ядра на процесора едновременно. Това е много важно, защото съвременният хардуер е оптимизиран за многозадачност и ако вашият код работи на една нишка (като NodeJS или Python), ще получите неоптимална производителност.

Паралелизмът ви дава тласък на производителността, но също така въвежда цяла плеяда от проблеми, които възникват само в асинхронни среди.

Синхронизация

Нишките се изпълняват по едно и също време, което означава, че могат да имат достъп до един и същ блок памет едновременно. Една нишка може да промени данните, докато другата чете и всичко може да се разпадне. За да предотвратим тези сценарии, трябва да синхронизираме споделените данни.

Синхронизирането в Java се постига с помощта на механизма за изчакване-известяване. Всеки обект в Java има тези методи и може да се използва като заключване. Когато една нишка има нужда от достъп до споделен заключен ресурс, тя може да извика метода за изчакване. Когато нишката извика чакането, тя ще бъде уведомена, след като ресурсът е наличен. Java се справя с това вместо нас. Той създава опашка от нишки, чакащи ресурса, и дава достъп до една по една. Когато дадена нишка вече не използва ресурса, тя може да използва функцията за уведомяване, за да сигнализира, че вече не се нуждае от заключването и може да бъде предадена на друга нишка.

Синхронизираната ключова дума е синтактична захар над това.

Нестатичните синхронизирани методи използват текущия обект като заключване ( this ), докато статичните синхронизирани методи използват самия обект на класа.

Можете също така да синхронизирате всеки кодов блок по този начин

За пълнота трябва да прочетете и за

Също така можете да проверите моята статия за виртуални нишки, които идват с java 19.

В заключение

Надявам се да намерите тази статия за полезна. Уведомете ме, ако съм сбъркал нещо или съм пропуснал нещо важно. Ако имате нужда да включа повече информация за определена тема, свързана с Java, уведомете ме в коментарите и аз ще я добавя.

Докато интервюирате, имайте предвид, че хората рядко се интересуват какви факти знаете за Java. Повечето компании имат нужда да разберете защо съществуват определени функции и какви са плюсовете и минусите на различните решения/подходи. Не можете да знаете всичко, езикът Java е огромен. Интервюиращите искат да видят вашия мисловен процес, покажете им, че мислите и мислите на глас. Познаването на куп факти за Java няма да остави дълготрайно впечатление у хората, но вашите умения за решаване на проблеми ще го направят.