Чи дійсно рядок Java непорушний?


399

Усі ми знаємо, що Stringна Java не змінюється, але перевірте наступний код:

String s1 = "Hello World";  
String s2 = "Hello World";  
String s3 = s1.substring(6);  
System.out.println(s1); // Hello World  
System.out.println(s2); // Hello World  
System.out.println(s3); // World  

Field field = String.class.getDeclaredField("value");  
field.setAccessible(true);  
char[] value = (char[])field.get(s1);  
value[6] = 'J';  
value[7] = 'a';  
value[8] = 'v';  
value[9] = 'a';  
value[10] = '!';  

System.out.println(s1); // Hello Java!  
System.out.println(s2); // Hello Java!  
System.out.println(s3); // World  

Чому ця програма працює так? І чому значення s1і s2змінюється, але ні s3?


394
Ви можете робити всілякі дурні хитрощі з відображенням. Але ви в основному порушуєте наклейку "недійсна гарантія, якщо її вилучено" в класі, як тільки ви це зробите.
cHao

16
@DarshanPatel використовуйте SecurityManager, щоб відключити відображення
Шон Патрік Флойд

39
Якщо ви дійсно хочете возитися з речами, ви можете зробити так, щоб (Integer)1+(Integer)2=42возитися з кешованим автобоксингом; (Disgruntled-Bomb-Java-Edition) ( thedailywtf.com/Articles/Disgruntled-Bomb-Java-Edition.aspx )
Річард Тінгл

15
Вас може здивувати ця відповідь, яку я писав майже 5 років тому stackoverflow.com/a/1232332/27423 - мова йде про незмінні списки в C #, але в основному те саме: як я можу зупинити користувачів від зміни моїх даних? І відповідь, ви не можете; рефлексія робить це дуже легко. Однією з основних мов, яка не має цієї проблеми, є JavaScript, оскільки вона не має системи відображення, яка може отримати доступ до локальних змінних всередині закриття, тому приватне дійсно означає приватне (хоча для нього немає ключового слова!)
Daniel Earwicker

49
Хтось читає питання до кінця ?? Питання, дозвольте мені повторити: "Чому ця програма працює так? Чому значення s1 і s2 змінені, а не змінені для s3?" Питання НЕ, чому змінюються s1 і s2! Питання ТАКЕ: Чому s3 не змінено?
Roland Pihlakas

Відповіді:


403

String є незмінним *, але це означає лише, що ви не можете його змінити, використовуючи його загальнодоступний API.

Що ви тут робите, це обійти звичайний API, використовуючи рефлексію. Таким же чином, ви можете змінити значення перерахунків, змінити таблицю пошуку, яка використовується в автобоксінгу Integer і т.д.

Тепер причина s1та s2зміна значення полягають у тому, що вони обоє посилаються на один інтернований рядок. Укладач робить це (як згадується в інших відповідях).

Причина s3насправді не була для мене трохи несподіваною, оскільки я думав, що вона поділить valueмасив ( це було в попередній версії Java , перед Java 7u6). Однак, дивлячись на вихідний код String, ми можемо побачити, що valueмасив символів для підрядків насправді скопійовано (використовуючи Arrays.copyOfRange(..)). Ось чому це залишається незмінним.

Ви можете встановити SecurityManager, щоб уникнути зловмисного коду для таких дій. Але майте на увазі, що деякі бібліотеки залежать від використання подібних прийомів відображення (як правило, інструменти ORM, бібліотеки AOP тощо).

*) Я спочатку писав, що Strings насправді не незмінні, а лише "ефективні незмінні". Це може ввести в оману в поточній реалізації String, де valueмасив дійсно позначений private final. Однак все-таки варто зауважити, що немає можливості оголосити масив у Java незмінним, тому слід бути обережним, щоб не виставляти його за межі свого класу, навіть із належними модифікаторами доступу.


Оскільки ця тема здається надзвичайно популярною, ось деякі поради щодо подальшого читання: Розмова про божевілля відбиття Хайнца Кабуца з JavaZone 2009, яка висвітлює багато питань ОП, а також інші роздуми ... ну ... божевілля.

Він висвітлює, чому це іноді корисно. І чому, більшість часу, вам слід цього уникати. :-)


7
Власне, Stringінтернування є частиною JLS ( "рядковий літерал завжди посилається на один і той же екземпляр класу String" ). Але я згоден, розраховувати деталі реалізації Stringкласу не надто добре .
haraldK

3
Можливо, причина, чому substringкопії, а не використання "розділу" існуючого масиву, полягає в тому, що якщо б я мав величезний рядок sі вийняв з нього крихітну підрядку t, а пізніше я відмовився, sале зберігав t, то величезний масив буде збережений (не зібране сміття). Тож, може, для кожного значення рядка більш природно мати свій асоційований масив?
Джеппе Стіг Нільсен

10
Спільне використання масивів між рядком та його підрядками також означало, що кожен String екземпляр повинен містити змінні для запам'ятовування зміщення у згаданий масив та довжину. Це накладні витрати, які не можна ігнорувати, враховуючи загальну кількість рядків і типове співвідношення між звичайними рядками та підрядками в додатку. Оскільки їх доводилося оцінювати для кожної струнної операції, це означало уповільнення кожної рядкової операції лише на користь лише однієї операції, дешевої підрядки.
Хольгер

2
@Holger - Так, я розумію, що поле зміщення було впало в останніх СП. І навіть коли він був присутній, його застосовували не так часто.
Гарячі лизання

2
@supercat: не має значення, ти маєш нативний код чи ні, маєш різні реалізації для рядків і підрядків у межах одного JVM або маєш byte[]рядки для рядків ASCII та char[]для інших означає, що кожна операція повинна перевіряти, який тип рядка знаходиться раніше діючі. Це перешкоджає вкладенню коду в методи за допомогою рядків, що є першим кроком подальшої оптимізації з використанням контекстної інформації абонента. Це великий вплив.
Холгер

93

У Java, якщо дві рядкові примітивні змінні ініціалізовані на один і той же літерал, він призначає однакове посилання на обидва змінні:

String Test1="Hello World";
String Test2="Hello World";
System.out.println(test1==test2); // true

ініціалізація

Саме тому порівняння повертає істину. Третя рядок створюється за допомогою substring()якої робить новий рядок, а не вказує на той самий.

підряд

Коли ви отримуєте доступ до рядка за допомогою відображення, ви отримуєте фактичний вказівник:

Field field = String.class.getDeclaredField("value");
field.setAccessible(true);

Тож зміна на це змінить рядок, що утримує вказівник на неї, але, як s3створено з новою рядком, завдяки substring()їй не зміниться.

змінити


Це працює лише для літералів і є оптимізацією часу компіляції.
SpacePrez

2
@ Zaphod42 Неправда. Ви також можете internвручну зателефонувати на нелітеральну строку і отримати переваги.
Кріс Хейс

Однак зауважте: ви хочете використовувати internрозумно. Інтернування всього не приносить тобі багато чого, і може стати джерелом деяких моментів, що чухають голову, коли ти додаєш рефлексію до суміші.
cHao

Test1і Test1не відповідають test1==test2і не дотримуються конвенцій про іменування Java.
c0der

50

Ви використовуєте рефлексію, щоб обійти незмінність String - це форма "атаки".

Ви можете створити так багато прикладів (наприклад, ви можете навіть створити інстанцію Voidоб'єкта ), але це не означає, що String не є "незмінним".

Існують випадки використання, коли цей тип коду може використовуватися на вашу користь і бути «хорошим кодуванням», наприклад, очищення паролів із пам’яті в найкоротший момент (перед GC) .

Залежно від менеджера безпеки, ви, можливо, не зможете виконати свій код.


30

Ви використовуєте відображення для доступу до "деталей реалізації" рядкового об'єкта. Незмінюваність - це особливість публічного інтерфейсу об'єкта.


24

Модифікатори видимості та остаточний (тобто незмінність) не є показником проти шкідливого коду на Java; вони просто інструменти для захисту від помилок і для того, щоб зробити код більш рентабельним (одна з найбільших точок продажу системи). Ось чому ви можете отримати доступ до деталей внутрішньої реалізації, таких як масив резервних символів дляString s за допомогою відображення.

Другий ефект, який ви бачите, - це те, що всі Stringзмінюються, але схоже, що ви лише змінюєтеся s1. Певна властивість літераторів Java String полягає в тому, що вони автоматично інтерніруються, тобто кешуються. Два літеральні рядки з однаковим значенням насправді будуть одним і тим же об'єктом. Коли ви створюєте рядок, newвона не буде інтернована автоматично, і ви не побачите цього ефекту.

#substringдонедавна (Java 7u6) працював аналогічно, що пояснювало б поведінку в оригінальній версії вашого питання. Він не створив новий масив резервних символів, але повторно використав його з оригінального рядка; він просто створив новий об'єкт String, який використовував зміщення та довжину для подання лише частини цього масиву. Це, як правило, спрацьовує, оскільки струни незмінні - якщо тільки не обійти це. Ця властивість #substringтакож означала, що всю оригінальну струну не можна збирати сміттям, коли коротша підрядка, створена з неї, все ще існувала.

Що стосується поточної Java та вашої поточної версії питання, то дивної поведінки не існує #substring.


2
Насправді модифікатори видимості призначені (або, принаймні, вони) призначені як захист, що повторює шкідливий код - однак для активації захисту потрібно встановити SecurityManager (System.setSecurityManager ()). Наскільки безпечно це насправді, є іншим питанням ...
sleske

2
Заслуговує на обґрунтування, оскільки ви підкреслюєте, що модифікатори доступу не призначені для "захисту" коду. Здається, це широко не зрозуміло як у Java, так і в .NET. Хоча попередній коментар суперечить цьому; Я мало знаю про Java, але в .NET це, безумовно, правда. Користувачі не повинні вважати, що це робить їх код несанкціонованим.
Том Ш

Порушити договір finalнавіть через роздуми неможливо . Крім того, як згадується в іншій відповіді, оскільки Java 7u6 #substringне використовує масиви.
ntoskrnl

Насправді поведінка finalзмінилася з часом ...: -O Згідно з розмовою Хайнца, яку я опублікував у іншому потоці, я finalмав на увазі остаточне в JDK 1.1, 1.3 та 1.4, але міг бути модифікований за допомогою відображення, використовуючи 1,2 завжди , а в 1,5 та 6 у більшості випадків ...
haraldK

1
finalполя можуть бути змінені за допомогою nativeкоду, як це зроблено рамкою серіалізації при зчитуванні полів серіалізованого екземпляра, а також System.setOut(…)що змінює остаточну System.outзмінну. Останнє є найцікавішою особливістю, оскільки відображення з переопрацюванням доступу не може змінити static finalполя.
Холгер

11

Незмінність рядків - з точки зору інтерфейсу. Ви використовуєте відображення, щоб обходити інтерфейс і безпосередньо змінювати внутрішні елементи String-екземплярів.

s1і s2обидва вони змінені, оскільки вони обоє призначені одному і тому ж "інтерну" екземпляру String. Ви можете дізнатися трохи більше про цю частину з цієї статті про рівність рядків та інтернування. Ви можете бути здивовані, дізнавшись, що у вашому зразковому коді s1 == s2повертається true!


10

Яку версію Java ви використовуєте? Від Java 1.7.0_06 Oracle змінив внутрішнє представлення String, особливо підрядкової.

Цитуючи представлення внутрішнього рядка Java Ones Tunes :

У новій парадигмі поля зміщення рядків і рядка було видалено, тому підрядки більше не поділяють базове значення char [].

З цією зміною воно може статися без роздумів (???).


2
Якщо ОП використовував старіший JRE Sun / Oracle, останній вислів надрукував би "Java!" (як він випадково виклав). Це впливає лише на обмін масивом значень між рядками та підрядками. Ви все одно не можете змінити значення без хитрощів, як-от рефлексія.
haraldK

7

Тут справді два питання:

  1. Чи справді рядки незмінні?
  2. Чому s3 не змінено?

До пункту 1: За винятком ROM, у вашому комп’ютері немає незмінної пам'яті. В даний час навіть ПЗУ іноді можна записати. Завжди десь є якийсь код (будь то ядро ​​чи рідний код, який проходить у керованому середовищі), який може записувати на вашу пам'ять. Так, у "реальності" ніякі вони не є абсолютно незмінними.

До пункту 2: Це пов'язано з тим, що підрядка, ймовірно, виділяє новий екземпляр рядка, який, ймовірно, копіює масив. Можна реалізувати підрядку таким чином, що вона не буде робити копію, але це не означає, що це робить. Задіяні компроміси.

Наприклад, чи повинно мати посилання, щоб reallyLargeString.substring(reallyLargeString.length - 2)викликати збереження великої кількості пам'яті або лише кілька байт?

Це залежить від того, як реалізована підрядка. Глибока копія збереже менше пам'яті, але вона буде працювати трохи повільніше. Неглибока копія збереже більше пам’яті в живих, але це буде швидше. Використання глибокої копії також може зменшити фрагментацію купи, оскільки рядовий об'єкт та його буфер можуть бути розподілені в одному блоці, на відміну від двох окремих розподілів купи.

У будь-якому випадку, схоже, ваш JVM вирішив використовувати глибокі копії для дзвінків підрядків.


3
Справжній ПЗУ такий же непорушний, як і фотодрук, укладений у пластик. Візерунок постійно встановлюється, коли пластинка (або принт) хімічно розроблена. Електрично змінні пам'яті, включаючи мікросхеми оперативної пам'яті , можуть вести себе як "справжній" ПЗУ, якщо керуючі сигнали, необхідні для його запису, не можуть бути заряджені без додавання додаткових електричних з'єднань до ланцюга, в якому він встановлений. Насправді не рідкість, коли вбудовані пристрої включають оперативну пам’ять, яка встановлена ​​на заводі та підтримується резервним акумулятором, і вміст якої потрібно буде перезавантажити на заводі, якщо баттай не вийшов з ладу.
supercat

3
@supercat: Однак ваш комп'ютер не є однією з таких вбудованих систем. :) Справжні жорсткопровідні ПЗУ не були поширеними на ПК протягом десяти років або двох; все EEPROM і спалахне в наші дні. В основному кожна видима користувачеві адреса, яка відноситься до пам'яті, відноситься до потенційно записаної пам'яті.
cHao

@cHao: Багато флеш-мікросхем дозволяють захищати частини таким чином, що, якщо її взагалі неможливо відмінити, потрібно буде застосовувати різні напруги, ніж це потрібно для нормальної роботи (для чого материнські плати не були б обладнані). Я б очікував, що материнські плати будуть використовувати цю функцію. Далі я не впевнений у сучасних комп’ютерах, але історично деякі комп’ютери мали область оперативної пам’яті, захищена від запису під час завантаження і могла бути незахищеною лише шляхом скидання (що змусить виконання запуску з ПЗУ).
supercat

2
@supercat Я думаю, що ви пропускаєте суть теми, а саме те, що рядки, що зберігаються в оперативній пам’яті, ніколи не будуть по-справжньому непорушними.
Скотт Вішневський

5

Щоб додати відповідь @ haraldK - це хакер безпеки, який може призвести до серйозного впливу на додаток.

Перше - це зміна постійної рядки, що зберігається в String Pool. Коли рядок оголошується як " String s = "Hello World";," він розміщується у спеціальному об'єктному пулі для подальшого потенційного використання. Проблема полягає в тому, що компілятор розмістить посилання на модифіковану версію під час компіляції, і як тільки користувач змінить рядок, що зберігається в цьому пулі під час виконання, всі посилання в коді будуть вказувати на модифіковану версію. Це призведе до наступної помилки:

System.out.println("Hello World"); 

Буде надруковано:

Hello Java!

Було ще одне питання, з яким я зазнав, коли проводив важкі обчислення таких ризикованих рядків. Під час обчислень сталася помилка, яка сталася приблизно 1 із 1000000 разів, що призвело до неефективності результату. Мені вдалося знайти проблему, вимкнувши JIT - я завжди отримував однаковий результат із вимкненим JIT. Я здогадуюсь, що причиною цього був хакер String Security, який порушив деякі контракти на оптимізацію JIT.


Це могло бути проблемою безпеки потоку, яку замаскували більш повільний час виконання та менша паралельність без JIT.
Тед Пеннінгз

@TedPennings З мого опису це могло, я просто не хотів занадто багато вникати в деталі. Я фактично провів, як пару днів, намагаючись локалізувати це. Це був однопоточний алгоритм, який обчислював відстань між двома текстами, написаними двома різними мовами. Я знайшов два можливі виправлення проблеми: один - вимкнути JIT, а другий - додати буквально неоперативні дії String.format("")в одну з внутрішніх циклів. Існує ймовірність того, що це буде якась інша, а потім невдача JIT, але я вважаю, що це був JIT, тому що ця проблема ніколи не відтворювалася після додавання цієї не-оп.
Андрій Чащев

Я робив це з ранньою версією JDK ~ 7u9, так що це могло бути.
Андрій Чащев

1
@Andrey Chaschev: "Я знайшов два можливі виправлення проблеми" ... третій можливий виправлення, щоб не зламатись у Stringвнутрішні місця, не прийшов вам на думку?
Хольгер

1
@ Ted Pennings: проблеми безпеки потоку та проблеми JIT часто однакові. JIT дозволяється генерувати код, який спирається на finalгарантії безпеки польових потоків, які порушуються при зміні даних після побудови об'єкта. Таким чином, ви можете розглядати це як випуск JIT або випуск MT так, як вам подобається. Справжньою проблемою є взлом Stringта зміна даних, які, як очікується, незмінні.
Хольгер

5

Відповідно до концепції об'єднання, всі змінні String, що містять однакове значення, вказуватимуть на одну і ту ж адресу пам'яті. Тому s1 і s2, що містять однакове значення "Hello World", будуть вказувати на одне місце пам'яті (скажімо, M1).

З іншого боку, s3 містить "Світ", отже, він вказуватиме на інше розподілення пам'яті (скажімо, M2).

Отже, зараз відбувається те, що значення S1 змінюється (за допомогою значення char []). Таким чином, значення в місці пам'яті M1, вказуваному як s1, так і s2, було змінено.

Отже, місце M1 пам'яті було змінено, що спричиняє зміну значень s1 і s2.

Але значення місця M2 залишається незмінним, отже, s3 містить те саме початкове значення.


5

Причина s3 насправді не змінюється в тому, що в Java, коли ви робите підрядку, масив значень масиву для підрядки внутрішньо копіюється (використовуючи Arrays.copyOfRange ()).

s1 і s2 однакові, тому що в Java вони обоє посилаються на одну і ту ж інтерновану рядок. Це за дизайном на Java.


2
Як ця відповідь щось додала до відповідей перед вами?
Сірий

Також зауважте, що це зовсім нова поведінка, і це не гарантується жодною специфікацією.
Paŭlo Ebermann

Реалізація String.substring(int, int)змінена з Java 7u6. Перед 7u6, віртуальна машина буде просто тримати покажчик на оригінал String«S char[]разом з індексом і довжиною. Після 7u6 він копіює підрядку в нову StringЄ плюси і мінуси.
Ерік Яблов

2

Рядок є непорушним, але через відображення вам дозволяється змінити клас String. Ви щойно переосмислили клас String як змінний в режимі реального часу. Ви можете переосмислити методи, щоб бути державними, приватними або статичними, якщо хочете.


2
Якщо ви поміняєте видимість полів / методів, це не корисно, оскільки під час компіляції вони є приватними
Bohemian

1
Ви можете змінити доступність методів, але ви не можете змінити їх державний / приватний статус, і ви не можете зробити їх статичними.
Сірий

1

[Відмова від цього - це свідомо висловлюваний стиль відповіді, оскільки я вважаю, що відповідь "не робіть цього вдома дітьми" є гарантованою]

Гріх - це лінія, field.setAccessible(true);яка говорить про порушення громадських програм, дозволяючи доступ до приватного поля. Це гігантський отвір у безпеці, який можна закрити, налаштувавши менеджер з безпеки.

Явище у питанні - це деталі реалізації, яких ви ніколи не побачите, не використовуючи небезпечну лінію коду, щоб порушити модифікатори доступу через відображення. Очевидно, що два (звичайно) незмінні рядки можуть мати спільний масив char. Від того, чи має одна підрядка однаковий масив, залежить від того, чи може вона та чи думав розробник поділитися нею. Зазвичай це невидимі деталі реалізації, про які ви не повинні знати, якщо ви не знімете модифікатор доступу через голову цим рядком коду.

Просто не годиться покладатися на такі деталі, які неможливо зазнати без порушення модифікаторів доступу за допомогою відображення. Власник цього класу підтримує лише звичайний загальнодоступний API і може вносити зміни до впровадження в майбутньому.

Сказавши все, що рядок коду справді дуже корисний, коли у вас тримається пістолет, ви змушуєте вас робити такі небезпечні речі. Використання задньої двері - це зазвичай запах коду, який потрібно оновити до кращого коду бібліотеки, де не потрібно грішити. Ще одне поширене використання цього небезпечного рядка коду - це написання "рамки вуду" (orm, контейнер для ін'єкцій, ...). Багато людей стають релігійними щодо подібних рамок (і за, і проти них), тому я уникатиму запрошення вогню полум'я, кажучи, що більше, ніж переважна більшість програмістів не повинні туди їхати.


1

Струни створюються в постійній зоні купи пам'яті JVM. Так що так, це дійсно незмінне і не може бути змінено після створення. Тому що в JVM існує три типи пам’яті купи: 1. Молоде покоління 2. Старе покоління 3. Постійне покоління.

Коли будь-який об’єкт створений, він переходить у кучу молодого покоління та область PermGen, зарезервовану для об'єднання String.

Ось більш детальну інформацію можна отримати та отримати додаткову інформацію з: Як працює збір сміття на Java .


Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.