Java线程详解二:线程安全

1. 线程安全性

什么是线程安全?

比较学术的回答时是:多线程的环境中代码每次运行都能获得正确的结果,那么代码就是线程安全的。但这个回答看似回答了但好像有什么都没有解释。所以我们从另一个角度来看,即在多线程环境下那些情况会导致"线程安全"完全问题,已经这些情况会带来那些危害。

首先多线程环境下的线程安全问题主要源于多线程环境下一些共享的可变的状态可能会被多个线程所访问和修改。

当多个多线程对同一共享的状态进行读取和修改时就可能会破坏程序的原子性、可见性和有序性。所以要解决线程安全问题核心就在于对状态的读取和修改操作进行管理,特别是共享和可变的状态。共享意味着状态以被多个线程同时访问,而可变意味着状态在其生命周期内会发生变化。

好在 Java 作为一个成熟语言在语法和工具层面都提供了很多以解决线程安全问题。

1.1. 原子性

原子(atom)是指化学反应不可再分的基本微粒。在计算机中有原子操作的概念,指操作发起后直执行到结束期间不会被中断,同时执行期间读取和修改的数据其它线程不能访问和修改。基本上对内存的单一操作 CPU 都会保证其原子性,例如从内存中读取或写入一个字节。

在单线程环境中我们基本不用考虑操作的原子性问题,因为中断都是以指令为单位的,当前指令执行完成后才能被中断,并且由于只有一个线程,所以即使 CPU 发生了的线程切换,切换的也是其它进程的线程,而进程的内存空间是私有的,其状态并不会被其它线程所读取或修改。

但在多线程环境中,当前线程中操作依赖的状态可能会被其它线程所修改,使当前线程依赖的状态失效,导致操作得不到预期的结果,从而破坏了操作的原子性。

尤其是大多数操作在代码中看起来是一个操作,但编译后实际是由多个指令组合而成的复合操作,复合操作中后面的指令可能依赖前面指令的状态。例如一个简单 i++ 操作是由三个操作组合而成:从内存中 i 的值、 i 的值加1,新的值写回内存。

如下所示,两个线程同时对初始值为0 的 value执行++的操作,最终的结果可能是1而非预期的2:

当线程1开始执行 +1操作时,线程2读取到的 value 的值就已经失效了,同样线程2执行 +1 操作时线程 1 读取到 value 值也是失效的。

更复杂的是操作中可能涉及多个变量且各个变量之间并不是彼此独立的。同时某个变量的值会对其他变量的值产生约束,这时我们需要保证更新一个变量时,也要在同一个操作中原子的同步更新其他变量。例如一个集合中保存了各个元素,同时也有另外一个变量记录了集合中元素的个数,那么当向集合中添加元素时不仅要将元素保存进去还需要同步更新元素的个数。

在多线程环境中多个线程会去更新同一状态时,操作的原子性就有可能会被破坏,所以在多线程环境中要保证线程安全性只需要保证共享的状态不会被多个线程同时修改即可,常用的方式是使用同步机制对会被修改状态的代码加锁,以保证每次只能有一个线程在执行该代码。

非原子的64位操作

Java 内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非 volatile 类型的 long 和 double 变量,JVM 允许将 64 位的读操作或写操作分解为两个32 位的操作。当读取一个非 volatile 类型的long 变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32 位和另一个值的低32 位。

所以在多线程程序中使用共享且可变的 long 和 double 等类型的变量也是不安全的,除非用关健字 volatile 来声明它们,或者用锁保护起来。

1.2. 可见性

在单线程环境中,如果向某个变量先写入值,然后在没有其他写入操作的情况下读取这个变量,那么总能得到相同的值,这看起来很自然,然而在多线程环境中由于 CPU 缓存的存在,修改后的数据先更新到缓存中,而读取数据也是先从缓存中读取。当读和写在不同的线程不同的线程中执行时我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。

多个线程访问一个共享的可变的变量时就可能获取到'失效数据',读线程查看变量时,可能会得到一个已经失效的值。除非在每次访问变量时都使用同步,否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:一个线程可能获得某个变量的最新值,而获得另一个变量的失效值。

线程在没有同步的下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值,这种安全性保证也被称为最低安全性。

为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。同步代码块和同步方法不仅可以确保以原子的方式执行操作,还有另一个重要的方面:内存可见性。在有读操作和写操作的线程都在同一个同步锁上,可以确保某个线程写入该变量的值对于其他线程来说都是可见的。当然,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效值。

Volatile变量

Java 语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,volatile变量不会被缓存在寄存器或对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。同时也不会将该变量上的操作与其他内存操作一起重排序。

在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞。

1.3. 有序性

多个线程并发执行时,由于线程的执行顺序的不确定,可能导致程序的输出结果与预期的结果不一致的情况,即计算的的正确性取决于多个线程交替执行时序,这种情况也被称为竞争件。竞争条件不仅会使计算正确性的取决于无法控制的线程执行时序,也会破坏操作的原子性。

最常见就是先检查后执行的操作,例如下面生成单实例的方法:

typescript 复制代码
public class InstFactory {
    private Object inst;

    public Object getInst() {
        if (inst == null) {
            inst = new Object();
        }
        return inst;
    }
}

这个代码在单线程环境下或许没有什么问题,但在多线程环境下若两个线程同时第一次调用 getInst 方法,那么就可能分别 new 一个实例返回的值也不相同。

要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。

同时大多数现代处理器都会采用将指令乱序执行来提升执行效率,在条件允许的情况下,直接运行当前可以立即执行的后续指令,避开获取下一条指令所需数据时造成的等待。通过乱序执行的技术,处理器可以大大提高执行效率。除了处理器,常见的Java运行时环境的JIT编译器也会做指令重排序,即生成的机器指令与字节码指令顺序不一致。

Java编译器、运行时和处理器在重排序时,都会保证单线程下的as-if-serial语义。但无法保证多线程环境下 as-if-serial语义 。

在没有同步的情況下,编译器,处理器以及运行时等都可能对操作的找行顺序进行一些意想不到不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的顺序进行判断,几乎无法得出正确的结论。但使用同步可以避免治理的重排序,对应同步的代码块编译器和处理器都不会去对其进行重排序。正确的使用同步可以避免重排序问题。

2. 实现线程安全

线程安全的代码需要在多线程的环境中不管线程采用何种调度方式和如何交替执行,并且调用该代码的的主调代码不需要采用任何同步或协同,这个代码都能表示出正确的行为,那么这个代码就是线程安全。

编写线程安全的代码的关键在于对代码中共享的可变状态的访问进行管理,这些状态一般存在变量中,也可以说是对变量的访问进行管理。若代码中没有共享的专题,那么代码自然就是线程安全的,或者共享的变量时只读的,并且在被多个线程访问之前就有一个确定的初始的值,那么后续无论线程访问都只能获取到一个确定的值,所以这时代码也是线程安全的。

但若有多个线程访问某个变量,并且其中会用线程执行写操作时,就需要采用同步机制来协同这些线程对变量的访问。有三种方法保证状态的线程安全:

  1. 不在线程之间共享该状态变量。
  2. 将状态变量设置为不可变的变量。
  3. 在访问变量时使用同步。

2.1. 线程封闭

当状态变量只在当前线程中可见识,那么该变量也就只会被一个线程所读写,那状态自然就是线程安全问的,即使被封闭的状态本身不是线程安全的。同时也不需要使用同步来协同线程对变量的访问。这种技术被称为线程封闭(Thread Confinement)。它是实现线程安全性的最简单方式之一。

在Java语言中并没有强制规定某个变量必须由锁来保护,同样在Java语言中也无法强制将变量封闭在某个线程中。线程封闭是在程序设计中的一个考虑因素,必须在程序中自己实现。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal 类,但即便如此,我们仍然需要负责确保封闭在线程中的对象不会从线程中逸出。

对象池利用了串行线程封闭,将对象"借给"一个请求线程。只要对象池包含足够的内部同步来安全地发布池中的对象,并且只要客户代码本身不会发布池中的对象,或者在将对象返回给对象池后就不再使用它,那么就可以安全地在线程之间传递所有权。

2.1.1. 栈封闭

局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭(也被称为线程内部使用或者线程局部使用)比完全自己实现线程封闭更易于维护,也更加健壮。

对于基本类型的局部变量,无论如何都不会破坏栈封闭性。由于任何方法都无法获得对基本类型的引用,因此 Java 语言的这种语义就确保了基本类型的局部变量始终封闭在线程内。但在维持对象引用的栈封闭性时,我们需要多做一些工作以确保被引用的对象不会逸出。

如果非线程安全的对象只在一个线程内部的(Within-Thread)上下文中被使用,那么该对象仍然是线程安全的。然而,要小心的是,只有编写代码的开发人员才知道哪些对象需要被封闭到执行线程中,以及被封闭的对象是否是线程安全的。如果没有明确地说明这些需求,那么后续的维护人员很容易错误地使对象逸出。

2.1.2. ThreadLocal类

维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与线程关联起来。ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个 Connection 对象。由于 JDBC 的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将 JDBC 的连接保存到 ThreadLocal 对象中,每个线程都会拥有属于自己的连接。

当某个线程初次调用 ThreadLocal.get 方法时,就会调用 initialValue 来获取初始值。从概念上看,你可以将 ThreadLocal 视为包含了 Map<Thread, T>对象,其中保存了特定于该线程的值,但 ThreadLocal 的实现并非如此。这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。

2.1.3. volatile变量

在volatile变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共享的 volatile 变量上执行 "读取一修改一写入"的操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且volatile 变量的可见性保证还确保了其他线程能看到最新的值。

2.2. 不变性

实现线程安全需求的另一种方法是使用不可变状态。线程安全问题与多线程试图同时访问同一个可变的状态相关。如果状态不会改变,那么这些问题与复杂性也就自然消失了。

如果某个变量在被正确的创建后其状态就不能被修改,那么这个变量就称为不可变变量。线程安全性是不可变变量的固有属性之一。

虽然在 Java 语言规范和 Java 内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将变量声明为final类型。

但若变量是一个对象,那么变量的状态除了其自身以为还保护其对象上的其其它状态,例如一个 Map 类型的变量,即使我们声明其为 final类型,但其保护的元素是可变的,所以改变量并非线程安全的。对于对象除了其自身外还要保证其类中包含状态也是不可变,这个对象才是不可变对象。

2.3. 用锁来保护状态

由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。

当某个状态由锁来保护时,意味着在每次访问这个状态时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个状态。每个共享的和可变的状态都应该只由同一个锁来保护。在这种情沉下,我们称状态变量是由这个锁保护的。

一种常见的错误是认为,只有在多个线程都会写入共享变量时才需要使用同步,然而事实并非如此,由于存在可见性问题,即使只用一个线程修对变量进行的写操作对其它线程可能并非实时可以将。这时也可以使用锁,锁除了可以保证操作的原子性外还可以使的被锁保护的变量对其它线程实时可见。

访问共享状态的复合操作,例如命中计数器的递增操作(读取一修改一写人)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的,如果用同步来协调对其个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。

并非所有状态都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。例如一个处理大规模数据的单线程程序,由于任何数据都不会在多个线程之间共享,因此在单线程程序中不需要同步。

2.3.1. 内置锁

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。

内置锁即使用 synchronized关键字,其默认使用类的当前对象作为加锁对象,若修饰的是静态方法则使用类的 Class 作为加锁对象。线程执行 synchronized 修饰的代码块时需要先获得对象的内置锁,锁同一时间内只能被一个线程所获取,但锁被占用时其它线程将被阻塞直到锁被释放后再次尝试获取锁。当 synchronized 修饰的代码块执行结束后对象上的锁会被自动释放。

例如上面单实例方法可以使用 synchronized 修饰,避免 getInst 被多个线程同时访问:

java 复制代码
public class InstFactory {
    private Object inst;

    public synchronized Object getInst() {
        if (inst == null) {
            inst = new Object();
        }
        return inst;
    }
}

内置锁是可重入的,即获得对象内置锁的线程可以再次重复获得锁。

3. 线程安全的类

在Java中一切都是对象,程序的状态都存在与对象的字段中,操作的代码封装在对象的方法中, 所以我们有必要单独看一下如何设计出线程安全的类。

线程安全的类要求,当多个线程访问这个类时,不管运行环境采用何种调度方式或者这些线程如何交替执行,并且调用类的主调代码中不采用任何同步或协同,这个类都能表现出正确的行为,那么这类就是线程安全的。

若一个对象是无状态的或者这个对象不会被多个线程访问,那么无需额外的操作这个对象就是线程安全的对象。但若对象的状态会被多个线程所访问,并且有线程会去执行写操作时,就需要采用同步机制来协同对对象可变状态的访问,否则可能会导致数据破坏以及其他不该出现的结果。

同样的设计线程安全的类关键在也于对对象的共享的可变的状态的访问进行管理,也就是存在于对象字段(静态的和非静态的)中的状态。但需要注意对象的状态不仅仅只有字段中直接存放的数据还包括其他依赖对象的域,例如,List类的状态不仅是存在元素的数组还包括数组中存放的各个元素。

设计线程安全的类时,一个很好的实践是将状态都封装到类中起,通过使用封装技术可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。在设计线程安全类的过程中,主要包合以下三个基本要索:

  • 找出构成对象状态的所有变量。
  • 找出约束状态变量的不变性条件。
  • 建立对象状态的并发访问管理策略。

分析对象的状态时,首先从对象的域开始,如果对象中所有的域都是基本类型的变量,那么这些域就是构成对象的全部状态。如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域。

对象与变量都有一个状态空间,他们的状态在空间内发生变化,而我们期望状态如何进行变化已经变化后的期望值,被成为状态的不变性条件。例如二叉搜索树中,左节点的值不能大于父节点的值,这就是二叉搜索树的一个变性条件。

线程安全的类在并发场景下需要保证类的状态的不变性条件,即并发执行时类的状态也总是能符合我们期望。所以设计线程安全的类时要找出约束状态变量的不变性条件,即我们首先要明确我们对状态的需求是什么。

在类中也可以包含同时约束多个状态变量的不变性条件。例如,在一个表示数值范围的类中可以包含两个状态变量,分别表示范围的上界和下界。类似于这种包含多个变量的不变性条件将带来原子性需求:这些相关的变量必须在单个原子操作中进行读取或更新。不能首先更新一个变量,然后释放锁并再次获得锁,然后再更新其他的变量。

再找出了状态和状态的不变性条件后,就需要设计状态的并发访问管理策略以保证再并发场景下状态的不变性条件不被破坏。策略中常用的手段包括不可变性、线程封闭与加锁等。

如果在一个不变性条件中包含多个变量,那么在执行任何访问相关变量的操作时,都必须持有保护这些变量的锁。

3.1. 实例封闭

封装简化了线程安全类的实现过程,它提供了一种实例封闭机制,通常也筒称为"封闭"。当一个对象被封装到另一个对象中时,能够访问到被封装对象的所有代码路径都是已知的。与对象可以由整个程序访问的情况相比,更易于对代码进行分析。封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。

将状态封装在对象内部, 可以将状态的访问限制在对象的方法上,从而更容易确保线程在访问状态时总能持有正确的锁。通过封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全的对象。

实例封闭是构建线程安全类的一个最简单方式,它还使得在锁策略的选择上拥有了更多的灵活性,不同的状态变量可以由不同的锁来保护。

但需要注意被封闭对象一定不能超出它们既定的作用域。如果将一个本该被封闭的对象发布出去,那么也能破坏封闭性。如果一个对象本应该封闭在特定的作用域内,那么让该对象逸出作用域就是一个错误。当发布其他对象时,例如迭代器或内部的类实例,可能会间接地发布被封闭对象,同样会使被封闭对象逸出。

正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略。

3.2. 不可变对象

与不可变变量一样,若如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象,线程安全性同样也是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。

当满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态就不能修改。
  • 对象的所有域那是final类型的。
  • 对象是正确创建的(在对象的创建期间,this引用没有逸出)。

3.3. 发布与逸出

"发布(Publish)"一个对象是指,使对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。如果在发布时要确保线程安全性,则可能需要同步。当某个不应该发布的对象被发布时,这种情况就被称为逸出。

当发布某个对象时,可能会间接地发布其他对象,例如:

java 复制代码
class Unsafestates {
    private String[] states = new String[] {"AK", "AL"...}
    
    public String [] getStates() {
        return states:
    }
}

如果按照上述方式来发布states,就会出现问题,因为任何调用者获取到 states 都能修改这个数组的内容。当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布,例如上面数组中中的元素。

当把一个对象传递给某个外部方法时,就相当于发布了这个对象。你无法知道哪些代码会行,也不知道在外部方法中究竟会发布这个对象,还是会保留对象的引用并在随后由另一个线程使用。

无论其他的线程会对已发布的引用执行何种操作,其实都不重要,因为误用该引用的风险始终存在。当某个对象逸出后,你必须假设有某个类或线程可能会误用该对象。这正是需要使用封装的最主要原因:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。

3.3.1. 安全发布

在某些情况下我们希望在多个线程间共享对象,这时就需要确保安全地进行共享。然而,如果只是像如下程序那样将对象引用保存到公有域中,那么还不足以安全地发布这个对象。

java 复制代码
//不安全的发布
public Holder holder;

public void initialize() {
    holder = new Holder (42);
}

由于存在可见性问题,其他线程看到的Holder对象可能处于不一致的状态,例如holder被其他线程改了,或者initialize未按期望的顺序调用。

但即使是构造方法中进行初始化也可能出现线程获取到失效的值。例如:

java 复制代码
public class Holder {
    private int n
    public Holder (int n) {
        this.n = n;
    }
    public void assertSanity() {
        if (n!= n)
            throw new AssertionError ("This statement is false.");
        }
    }
}

尽管在构造函数中设置的域值似乎是第一次向这些域中写入的值,因此不会有"更旧的"值被视为失效值,但Object的构造函数会在子类构造函数运行之前先将默认值写入所有的域。因此,除了发布对象的线程外,其他线程看到的Holder域可能是一个失效的值,一个空引用或者之前的旧值。然而,更糟糕的情况是,线程看到 Holder 引用的值是最新的,但 Holder 状态的值却是失效的值。

由于不可变对象是一种非常重要的对象,因此Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证,final的字段初始化就会被初始化为指定的值而非先初始化为默认值, 这样即使在发布不可变对象的引用时没有使用同步,也仍然可以安全地访问该对象。为了维持这种初始化安全性的保证,必须满足不可变性的所有需求:状态不可修改,所有域都是final 类型,以及正确的构造过程。

3.3.2. 常用的安全发布模式

要安全地发布一个对象,其中就要求对象的引用以及对象的状态必须同时对其他线程可见。一个正确的构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化涵数中初始化一个对象引用。
  • 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。
  • 将对象的引用保存到某个正确构造对象的 final 类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

静态的初始,通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器,例如:

java 复制代码
public static Holder holder = new Holder (42);

用锁保护对象的域,例如线程安全的容器(例如 Vector 或synchronizedList),在将对象放入到某个线程安全的容器时,安全容器内的状态都建由synchronized同步锁保护, 将满足上述最后一条需求。如果线程A将对象放入一个线程安全的容器,随后线程B读取这个对象,那么可以确保B能看到A设置的状态,即便在这段读/写的应用程序代码中没有包含显式的同步。

如果对象在构造后可以修改,那么安全发布只能确保"发布时"状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者由某个锁保护起来。

相关推荐
万亿少女的梦1684 分钟前
基于Spring Boot的网络购物商城的设计与实现
java·spring boot·后端
醒了就刷牙31 分钟前
黑马Java面试教程_P9_MySQL
java·mysql·面试
m0_7482336438 分钟前
SQL数组常用函数记录(Map篇)
java·数据库·sql
编程爱好者熊浪2 小时前
JAVA HTTP压缩数据
java
吴冰_hogan2 小时前
JVM(Java虚拟机)的组成部分详解
java·开发语言·jvm
白宇横流学长3 小时前
基于java出租车计价器设计与实现【源码+文档+部署讲解】
java·开发语言
数据小爬虫@5 小时前
Java爬虫实战:深度解析Lazada商品详情
java·开发语言
咕德猫宁丶5 小时前
探秘Xss:原理、类型与防范全解析
java·网络·xss
F-2H6 小时前
C语言:指针4(常量指针和指针常量及动态内存分配)
java·linux·c语言·开发语言·前端·c++
苹果酱05676 小时前
「Mysql优化大师一」mysql服务性能剖析工具
java·vue.js·spring boot·mysql·课程设计