破坏 java.lang.String


只手遮天
2023-07-17 14:48:33 63487
分类专栏: 资讯

介绍:字符串之间的相等的条件 
 在我们开始之前,我们看一下 JDK 中两个字符串相等需要什么。 
  
  为什么 "foo".equals("fox") 的结果是 false? 
  
 因为字符串是逐字符比较的,这两个字符串的第三个字符不同。 
  
  为什么 "foo".equals("foo") 的结果是 true? 
  
 你可能会认为在这种情况下,字符串也是逐字符比较的。但是字符串字面量是 intern 的,当相同的字符串在源代码中出现多次作为常量时,它不是具有相同内容的另一个字符串,这些字符串是同一个实例。 
 String.equals 的第一件事就是 if (this == anObject) { return true; },  这里的判断甚至都不会去看里面的内容。 
  
  为什么 "foo!".equals("foo!?") 的结果是 false? 
  
 从 JDK 9 开始(自从 JEP 254: 紧凑字符串),字符串在内部表示其内容为字节数组。"foo!" 只包含简单的字符,代码点小于 256。字符串类在内部使用 latin-1 编码来编码这样的值,每个字符一个字节。"foo!?" 包含一个不能用 latin-1 表示的字符(!?),所以它使用 UTF-16 来编码整个字符串,每个字符两个字节。String.coder 字段跟踪使用了两种编码中的哪一种。当比较两个使用不同 coder 的字符串时,String.equals 总是返回 false。它甚至不去看内容,因为如果一个字符串可以用 latin-1 表示,而另一个字符串不可以,那么它们就不能相同。难到,你会认为可以相等? 
 注意: 紧凑字符串(Compact Strings)特性可以禁用,但默认是启用的。本文假定它是启用的。 
  
 创建一个损坏的字符串 
  
  字符串是如何创建的? java.lang.String 是如何选择使用 latin-1 还是不使用它的? 
  
 可以通过多种方式创建字符串,我们将关注接受 char[] 的字符串构造函数。它首先尝试使用 StringUTF16.compress 将字符编码为 latin-1。如果失败,返回 null,构造函数退回到使用 UTF-16。这里是它的实现的简化版本。(为了可读性,我从实际实现中删除了不相关的间接调用、检查和参数,实际实现在这里(https://github.com/openjdk/jdk/blob/b3f34039fedd3c49404783ec880e1885dceb296b/src/java.base/share/classes/java/lang/String.java#L277-L279)和这里(https://github.com/openjdk/jdk/blob/b3f34039fedd3c49404783ec880e1885dceb296b/src/java.base/share/classes/java/lang/String.java#L4757-L4772)) 
 /** * 分配一个新的 {@code String} 以表示字符数组参数当前所包含的字符序列。 * 复制字符数组的内容;后续修改字符数组不会影响新创建的字符串。 */  public String(char value[]) {  byte[] val = StringUTF16.compress(value);  if (val != null) {    this.value = val;    this.coder = LATIN1;    return;  }  this.coder = UTF16;  this.value = StringUTF16.toBytes(value);} 
 这里有个 bug。这段代码并不总是保持 String.equals 的语义,我们之前讨论过。你看出来了吗? 
 javadoc 指出“对字符数组的后续修改不会影响新创建的字符串”。但是并发修改呢?在尝试将其编码为 latin-1 和将其编码为 UTF-16 之间,value 的内容可能已经改变了。这样我们就可以拥有只包含 latin-1 字符的字符串,但编码却为 UTF-16。 
 我们可以通过下面的方式触发这个竞争条件: 
 /** * 给定一个 latin-1 字符串,创建一个错误编码为 UTF-16 的副本。 */static String breakIt(String original) {  if (original.chars().max().orElseThrow() > 256) {    throw new IllegalArgumentException(        "只能打断 latin-1 字符串");  }    char[] chars = original.toCharArray();    // 在另一个线程中,反复将第一个字符在可作为 latin-1 编码和不可作为 latin-1 编码之间切换  Thread thread = new Thread(() -> {    while (!Thread.interrupted()) {      chars[0] ^= 256;     }  });  thread.start();    // 同时调用字符串构造函数,直到触发竞争条件  while (true) {    String s = new String(chars);    if (s.charAt(0) < 256 && !original.equals(s)) {      thread.interrupt();      return s;    }  }} 
 我们可以使用这种方法创建的“损坏字符串”具有一些有趣的特性。 
 String a = "foo";String b = breakIt(a);  // 它们不相等System.out.println(a.equals(b));// => false  // 它们确实包含相同的一系列字符System.out.println(Arrays.equals(a.toCharArray(),    b.toCharArray())); // => true  // compareTo 认为它们相等(尽管它的 javadoc// 指定“当且仅当 equals(Object) 方法返回 true 时,// compareTo 返回 0”)System.out.println(a.compareTo(b));// => 0  // 它们有相同的长度,一个是另一个的前缀,// 但反过来不是(因为如果它没有被破坏,// 一个 latin-1 字符串不能以一个非 latin-1 // 子串开头)。System.out.println(a.length() == b.length());// => trueSystem.out.println(b.startsWith(a));// => trueSystem.out.println(a.startsWith(b));// => false 
 没想到这样一个基础的 Java 类会有这种奇怪的行为。 
  
 神秘的远程作用 
 我们不仅可以创建一个“损坏的字符串”,我们还可以在另一个类中远程破坏一个字符串。 
 class OtherClass {  static void startWithHello() {    System.out.println("hello world".startsWith("hello"));  }} 
 如果我们在 IntelliJ 中编写这段代码,那么它会警告我们 Result of '"hello world".startsWith("hello")' is always 'true'。这段代码甚至不需要任何输入,但我们仍然可以通过注入一个损坏的 "hello" 来使其打印 false,通过 interning:我们在任何其他代码字面量提及或显式 intern 它之前就破坏一个包含 hello 的字符串,并 intern 该损坏版本。这样,我们就破坏了JVM 中的每个 "hello" 字符串字面量。 
 breakIt("hell".concat("o")).intern();OtherClass.startWithHello(); // 打印 false 
  
 挑战:空或非空? 
 使用我们的 breakIt 方法,我们可以创建任何 latin-1 字符串的等价但不相等的字符串。但是它对空字符串不起作用,因为空字符串没有任何字符来触发竞争条件。然而,我们仍然可以创建一个损坏的空字符串。我将这个作为一个挑战给读者。 
 具体来说:你能创造一个 java.lang.String 对象,对于该对象,以下是真的 :s.isEmpty() && !s.equals("")。不要作弊:你只允许使用公共 API 来做这件事,如,不允许使用 .setAccessible 访问私有字段,也不允许使用 instrumentation 相关的类(因为 Instrumentation 提供了一种机制,使得开发者可以在不修改原始代码的情况下,通过代理、注入代码和监视器等方式对应用程序进行动态修改和扩展)。 
 如果你挑战成功,请在这里告诉我。我会在以后更新这篇文章,添加你提交的答案。 
  
 揭晓答案 
 创建一个 "损坏的" 空字符串最简单的方法是使用 breakIt(" ").trim()。这是因为 trim 方法正确地假定,如果原始字符串包含  latin-1 字符,那么去除  latin-1 字符后的结果仍应包含非  latin-1 字符。这个答案是由:Zac Kologlu、Jan、ichttt、Robert(他正确地指出了我对 "> 256" 检查的偏差)给出。 
 我还收到了两个原创的只能在 JDK 19 上运行的 StringBuilder  解决方案。Ihor Herasymenko 提交了这段代码,该代码通过 StringBuilder 的 deleteCharAt 触发了一个竞态条件。 
 Ihor 使用 deleteCharAt : 
 public class BrokenEmptyStringChallenge {    public static void main(String[] args) {        String s = breakIt();        System.out.println("s.isEmpty() && !s.equals(\"\") = " + (s.isEmpty() && !s.equals("")));    }      static String breakIt() {        String notLatinString = "\u0457";        AtomicReference<StringBuilder> sb = new AtomicReference<>(new StringBuilder(notLatinString));          Thread thread = new Thread(() -> {            while (!Thread.interrupted()) {                sb.get().deleteCharAt(0);                sb.set(new StringBuilder(notLatinString));            }        });        thread.start();          while (true) {            String s = sb.get().toString();            if (s.isEmpty() && !s.equals("")) {                thread.interrupt();                return s;            }        }    }} 
 最后,Xavier Cooney 提出了这个绝妙的解决方案,它甚至不涉及任何并发操作。它从 CharSequence.charAt 抛出一个异常,从而导致 StringBuilder 的状态不一致来实现这个效果。这看起来像是另一个 JDK 的 bug。 
 Xavier 给出的从 CharSequence.charAt 抛出异常的方案: 
 // 要求 Java 19+  class WatSequence implements CharSequence {    public CharSequence subSequence(int start, int end) {        return this;    }    public int length() {        return 2;    }    public char charAt(int index) {        // 无需并发处理!        if (index == 1) throw new RuntimeException();        return '⁉';    }    public String toString() {        return "wat";    }}  class Wat {    static String wat() {        if (Runtime.version().feature() < 19) {            throw new RuntimeException("本示例在 java-19 之前的版本无法运行 :(");        }          StringBuilder sb = new StringBuilder();        try {            sb.append(new WatSequence());        } catch (RuntimeException e) {}          return new String(sb);    }      public static void main(String[] args) {        String s = wat();        System.out.println(s.isEmpty() && !s.equals(""));    }} 
 我已经将这个 bug 提交到 Java Bug 数据库中。
————————————————
版权声明:本文为CSDN博主「CSDN资讯」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/csdnnews/article/details/131733634

网站声明:如果转载,请联系本站管理员。否则一切后果自行承担。

本文链接:https://www.xckfsq.com/news/show.html?id=26489
赞同 0
评论 0 条
L1
粉丝 0 发表 47 + 关注 私信
上周热门
银河麒麟添加网络打印机时,出现“client-error-not-possible”错误提示  1448
银河麒麟打印带有图像的文档时出错  1365
银河麒麟添加打印机时,出现“server-error-internal-error”  1151
统信桌面专业版【如何查询系统安装时间】  1073
统信操作系统各版本介绍  1070
统信桌面专业版【全盘安装UOS系统】介绍  1028
麒麟系统也能完整体验微信啦!  984
统信【启动盘制作工具】使用介绍  627
统信桌面专业版【一个U盘做多个系统启动盘】的方法  575
信刻全自动档案蓝光光盘检测一体机  484
本周热议
我的信创开放社区兼职赚钱历程 40
今天你签到了吗? 27
信创开放社区邀请他人注册的具体步骤如下 15
如何玩转信创开放社区—从小白进阶到专家 15
方德桌面操作系统 14
我有15积分有什么用? 13
用抖音玩法闯信创开放社区——用平台宣传企业产品服务 13
如何让你先人一步获得悬赏问题信息?(创作者必看) 12
2024中国信创产业发展大会暨中国信息科技创新与应用博览会 9
中央国家机关政府采购中心:应当将CPU、操作系统符合安全可靠测评要求纳入采购需求 8

添加我为好友,拉您入交流群!

请使用微信扫一扫!