Java多线程与高并发

Java多线程与高并发

一、基本概念

1.0 字节和位的关系

一个字节(Byte)是计算机存储和传输数据的基本单位,它表示8个二进制位(bits)。因此,1个字节等于8个比特(bits)。

当谈到计算机数据存储和传输时,我们使用位(bit)和字节(byte)来表示不同的数据量。一个位代表一个二进制数字,可以是0或1。而一个字节由8个位组成,可以表示256种不同的状态。字节是计算机中最常用的存储单位,用于表示字符、整数、图像等各种数据类型。

在Java开发中,字节和位的关系非常重要。Java中的基本数据类型(如int、char)都占用特定数量的字节,以对应不同范围的数值。例如,int类型占用4个字节,可以表示范围更大的整数值。

理解字节和位的关系对于编写高效的程序至关重要。在处理大量数据时,我们需要考虑数据的字节大小和位操作的效率,以确保程序的性能和准确性。

1.1 进程和线程

  • 进程:操作系统进行资源分配和调度的基本单位,它包括程序、数据和进程控制块等。在Java中,一个进程通常对应着一个正在运行的Java虚拟机(JVM)实例,每个JVM实例都有自己独立的内存空间和系统资源。

  • 线程:进程中的执行单元,它是操作系统能够进行运算调度的最小单位(程序执行的基本单位)。在Java中,线程是通过Thread类来表示的,多个线程可以并发地执行,共享同一个进程的资源。线程的使用可以让程序更高效地利用多核处理器和提高并发性能。

1.2 程序的执行步骤

在Java中,程序的执行通常是从main方法开始的。当你运行一个Java程序时,JVM会首先加载并初始化指定的类,然后寻找该类中名为"main"、参数为String数组的方法。一旦找到了main方法,JVM会从这里开始执行程序。

简而言之,Java程序的执行流程可以概括为:

  1. JVM加载并初始化指定的类。
  2. 寻找该类中的main方法。
  3. 从main方法开始执行程序。

因此,main方法可以看作是Java程序的入口点,所有的操作和逻辑都可以从这里开始执行。

1.3 线程是如何调度的?

线程调度是操作系统或者虚拟机对线程执行的管理和安排,主要包括线程的创建、执行、切换和终止等操作。

在Java中,线程的调度由Java虚拟机(JVM)负责管理。JVM使用一种抢占式的调度算法来决定哪个线程可以获得CPU的执行时间,并且在多核处理器上实现并发执行。以下是一些关于Java线程调度的重要概念:

  1. 抢占式调度:JVM使用抢占式调度算法来确定线程的执行顺序。这意味着在任何时刻,JVM都可能会暂停当前正在执行的线程,并将CPU的控制权交给另一个优先级更高的线程。
  2. 优先级:每个Java线程都有一个优先级,用于指示线程对CPU资源的需求程度。优先级较高的线程在竞争CPU时间时更有可能被选中执行,但并不能保证一定会被选中。
  3. yield方法:线程可以调用yield方法来表明自己愿意放弃当前的CPU执行权,以便让其他线程有机会执行。
  4. sleep方法:线程可以调用sleep方法来暂时释放CPU执行权,让其他线程有机会执行,同时也可以在指定的时间后重新竞争CPU执行权。
  5. 等待和唤醒:线程可以通过等待(wait)和唤醒(notify/notifyAll)机制来实现线程间的协作和同步。

在操作系统层面,每个线程都被视为调度的基本单元。操作系统会根据一定的调度算法来决定哪些线程可以获得CPU的执行时间,并且在多核处理器上实现并发执行。常见的调度算法包括先来先服务(FCFS)、最短作业优先(SJF)、轮转调度(Round Robin)等。

对于Java来说,JVM会使用操作系统提供的线程调度机制来实现Java线程的调度。Java线程会被映射到操作系统原生的线程上,然后由操作系统进行调度和执行。这样做的好处是,Java程序可以跨平台运行,因为它们依赖于底层操作系统的线程调度能力。

总的来说,线程调度是由JVM根据线程的优先级和状态来进行决定的,开发人员可以通过设置线程的优先级、使用yield和sleep方法,以及合理设计多线程程序来影响线程的调度行为。

1.4 线程切换

线程切换是指在多线程环境下,操作系统或者虚拟机将CPU的执行权从一个线程转移到另一个线程的过程。当一个线程正在执行时,可能会因为某些原因(如时间片用完、等待I/O等)需要让出CPU,并将执行权交给其他线程。

线程切换的过程包括以下几个关键步骤:

  1. 保存上下文:当前执行的线程(即被切换出去的线程)的上下文信息(如寄存器状态、程序计数器等)会被保存起来,以便在切换回来时能够继续执行。
  2. 选择下一个线程:操作系统或者虚拟机会根据一定的调度算法,在就绪状态的线程中选择一个合适的线程作为下一个要执行的线程。
  3. 恢复上下文:被选中的下一个线程(即要切换进来的线程)的上下文信息会被恢复,使其能够从切换出去的地方继续执行。
  4. 执行线程:CPU的执行权被转移到被切换进来的线程,开始执行该线程的代码。

线程切换的频率对系统性能和响应时间有一定的影响。过多的线程切换会导致系统开销增加,降低系统的效率。因此,在设计多线程程序时,需要合理地控制线程的数量和调度策略,以提高系统的性能和资源利用率。

二、CAS

CAS是Compare and Swap(比较并交换)的缩写,是一种并发编程中常用的原子操作。它用于解决多线程环境下的数据同步问题。

CAS操作是一种无锁算法,它通过比较并交换操作数来实现线程间的安全同步。

CAS操作有三个参数:内存位置(或者说是一个变量的引用)、旧的预期值和新的值。它的执行过程如下:

  1. 读取内存位置的当前值(旧的预期值)。
  2. 比较旧的预期值与实际的当前值是否相等。如果相等,则说明没有其他线程修改过这个值,可以继续执行后续操作。
  3. 如果不相等,则说明有其他线程修改了这个值,CAS操作失败,重新从步骤1开始执行。
  4. 如果相等,则将新的值写入内存位置,完成交换操作。

CAS操作是原子性的,即在执行过程中不会被其他线程中断。它利用底层硬件提供的原子指令,确保了多线程环境下对共享变量的安全访问。

CAS操作通常用于实现无锁的数据结构和并发算法,例如无锁队列、自旋锁、线程池等。相比于使用锁机制,CAS操作避免了线程的阻塞和唤醒,减少了上下文切换的开销,提高了并发性能。然而,CAS操作也存在一些问题,例如ABA问题和自旋次数过多可能导致性能下降,需要开发者注意处理。

CAS描述

三、synchronized相关知识

3.1 对象在内存中的存储布局

3.2 synchronized锁的升级过程

以下为32位对象头描述:

以下是64位对象头描述:

说法1

当多个线程竞争同一个锁时,Java中的锁会经历如下的升级过程:

  1. 偏向锁(Biased Locking):在程序刚开始执行时,对象的锁处于偏向状态。当一个线程获得了对象的锁,并且在之后再次请求该对象的锁时,会使用偏向锁来避免同步操作。此时,锁会记录该线程的Thread ID,并将对象头中的Mark Word字段设置为偏向锁的标记。
  2. 轻量级锁(Lightweight Locking):(自旋锁)如果存在多个线程竞争同一个锁,偏向锁会升级为轻量级锁。此时,系统会使用CAS(Compare And Swap)操作来尝试获取锁。如果CAS操作成功,则表示该线程获得了锁,将对象头中的Mark Word字段设置为指向当前线程的锁记录(Lock Record)。如果CAS操作失败,表示其他线程已经获得了锁,需要进行进一步的升级。
  3. 自旋锁(Spin Locking):当在轻量级锁阶段,CAS操作失败时,线程不会立即阻塞,而是会进行自旋操作。自旋锁是为了避免线程频繁地进入和退出阻塞状态而引入的,它会让线程暂时空转一段时间,尝试获取锁。如果在自旋期间,锁的持有者线程释放了锁,那么当前线程可以快速获取锁,避免了线程阻塞和唤醒的开销。
  4. 重量级锁(Heavyweight Locking):如果经过一定次数的自旋操作后,仍然无法获取到锁,锁会升级为重量级锁。此时,线程将进入阻塞状态,并且锁的实现会涉及到操作系统的互斥量等底层操作。重量级锁的使用会带来较高的性能开销,因为涉及线程的切换和内核态与用户态之间的切换。

需要注意的是,锁的升级过程是动态的,会根据竞争情况和线程行为进行调整。Java虚拟机会根据程序运行时的实际情况来选择合适的锁状态,以保证程序的并发性能和线程安全性。

说法2
  1. 当只有一个线程去争抢锁的时候,会先使用偏向锁,就是给一个标识,说明现在这个锁被线程a占有;
  2. 后来又来了线程b、线程c,说凭什么你占有锁,需要公平的竞争,于是将标识去掉,也就是撤销偏向锁,升级为轻量级锁,三个线程通过CAS进行锁的争抢(其实这个抢锁过程还是偏向于原来的持有偏向锁的线程;
  3. 现在线程a占有了锁,线程b、线程c一直在循环尝试获取锁,后来又来了十个线程,一直在自旋,那这样等着也是干耗费CPU资源,所以就将锁升级为重量级锁,向内核申请资源,直接将等待的线程进行阻塞。
说法3
  1. 厕所里来了一个人,自己在门上贴了个标签"里面有人",这个叫偏向锁;

  2. 后来又来了一个人,他也拿着一张标签但是看到门口贴着标签呢,另一个人那来回来厕所门口看看这个过程叫CAS,此时升级为轻量级;

  3. 后来又来了几个人,一群人老来回看太费事了,管理员(内核态)就让他们出去排队等着,此时升级成重量级锁

什么情况下偏向锁才会升级为轻量级锁,什么时候轻量级锁才会升级为重量级锁?

  1. 只有一个线程的时候就是偏向锁(当偏向锁开启的时候,偏向锁默认开启),当争抢的线程超过一个,升级为轻量级锁;
  2. 当自旋的线程循环超过10次,或者线程等待的数量超过CPU的1/2,升级为重量级锁,其实轻量级锁就适用于那种执行任务很短的线程,可能通过一两次自旋,就能够获取到锁。

3.3 锁升级相关问题

3.3.1 开启偏向锁一定比轻量级锁高效吗?

不一定,比如在一开始已经知道某个资源就需要被多个线程争抢,此时就不需要开启偏向锁,因为偏向锁给了标识之后,还需要取消这个标识,重新抢锁,比如在JVM中,偏向锁默认是延迟4秒才开始的,因为JVM在启动的时候需要多个线程竞争资源,并且这个都是一开始知道的。

3.3.2 用户态和内核态在锁升级过程中起什么作用?

在操作系统中,用户态和内核态是针对操作系统和应用程序之间的特定权限级别的概念。在锁升级过程中,用户态和内核态起到了以下作用:

  1. 用户态:在用户态下运行的应用程序只能访问自己的内存空间和受限资源,无法直接访问操作系统内核的资源。因此,在锁升级过程中,当一个线程在用户态请求获取锁时,如果锁已经被其他线程占用,它会进入阻塞状态,切换到内核态去请求锁的释放。
  2. 内核态:在内核态下运行的操作系统内核具有更高的权限,可以访问系统的所有资源,并且可以执行特权指令。在锁升级过程中,当内核态的线程检测到有其他线程在用户态请求获取锁时,它会负责管理锁的分配和调度,并根据具体情况进行锁的升级或降级,以保证多线程程序的正确性和效率。

因此,在锁升级过程中,用户态和内核态的切换和权限管理是非常重要的。合理的锁升级策略可以最大程度地减少用户态和内核态的频繁切换,提高多线程程序的性能和响应速度。

3.3.3 轻量级锁升级为重量级锁请展开说说

在Java中,锁的升级过程通常涉及到轻量级锁和重量级锁之间的转换。轻量级锁是为了提高多线程竞争下的性能而设计的一种锁机制,它通过CAS(Compare and Swap)操作来尝试获取锁,如果成功则表示获得了锁,否则会升级为重量级锁。

当一个线程尝试获取轻量级锁时,它会先尝试使用CAS来将对象头中的标记位修改为指向线程栈中锁记录的指针,如果成功则表示获取了锁,整个过程不涉及系统调用,不需要进入内核态,因此性能较高。但是当有多个线程同时竞争同一个锁时,可能会导致轻量级锁的获取失败,这时就会发生锁升级为重量级锁的过程。

当轻量级锁获取失败时,虚拟机会将锁升级为重量级锁。在这种情况下,需要涉及到操作系统层面的内核态操作,即需要进行系统调用来申请锁资源,并且需要涉及到更复杂的锁管理机制。因为重量级锁是在操作系统的内核态下实现的,所以它的竞争代价更高,性能相对较低。

总的来说,轻量级锁升级为重量级锁意味着锁的竞争变得更加激烈,需要借助操作系统的内核态来进行资源的申请和管理,这会增加额外的开销和竞争代价。因此,在编写多线程程序时,需要注意锁的升级过程,避免频繁地发生锁的升级,以提高程序的并发性能。

3.3.4 升级为重量级锁之后,线程放在哪里?

在Java中,当锁升级为重量级锁之后,线程会被挂起并放置在操作系统内核态的等待队列中。这是因为重量级锁的实现通常会涉及到操作系统层面的同步原语,例如互斥量(mutex)或者其他形式的内核对象,这些同步原语通常由操作系统来管理。

一旦锁升级为重量级锁,持有该锁的线程将会进入内核态,并且会释放控制权,让其他线程能够获得CPU执行时间。同时,未能获取锁的线程将会被放置到操作系统内核态的等待队列中,以等待锁的释放。当锁的持有者释放锁时,操作系统会从等待队列中选取一个线程来获取锁,并将其唤醒并转移到用户态继续执行。

需要注意的是,操作系统的等待队列通常是由操作系统内核来管理的,它提供了对于等待线程的调度和唤醒机制,以确保锁的正确释放和获取。因此,锁升级为重量级锁后,线程的状态和调度都由操作系统来管理和调度,这也是重量级锁相比轻量级锁性能较低的一个重要原因。

3.4 锁消除(Lock Eliminate)

当一个方法中包含了某个对象的加锁操作,但通过静态分析或者动态检测发现这个对象只会被单个线程访问,而不会存在真正的多线程竞争时,即时编译器可以进行锁消除优化。

在Java中,锁消除通常发生在即时编译器(Just-In-Time Compiler,JIT)的优化过程中。当进行静态代码分析或者动态运行时监测时,如果发现某个锁在某段代码中并不会存在真正的竞争条件,就可以将这个锁消除掉,从而减少不必要的同步开销。

举一个简单的例子来说明锁消除:假设有如下的代码片段

public void doSomething() {
    Object obj = new Object();
    synchronized(obj) {
        // 一些操作
    }
}

通过静态分析或者动态检测,编译器可以确定在 doSomething() 方法中,obj 对象只会被单个线程访问,而不会被多个线程共享和竞争。在这种情况下,即时编译器可以进行锁消除优化,将 synchronized 块消除掉,因为实际上并不需要对 obj 进行同步操作。

经过即时编译器的锁消除优化后的代码可能会变成这样:

public void doSomething() {
    Object obj = new Object();
    // 一些操作
}

这样就避免了不必要的同步开销,提高了程序的性能和并发能力。这是一个简单的例子,实际的锁消除优化可能涉及更复杂的代码和数据访问模式,但原理是类似的。需要注意的是,虽然锁消除可以提高性能,但在实际应用中需要根据具体情况进行评估,并确保不会因为锁消除导致意外的并发问题。

3.5 锁粗化Lock Coarsening)

锁粗化是一种针对连续的加锁和解锁操作进行优化的技术,它的原理是将多个连续的加锁和解锁操作合并成一个更大的锁范围,从而减少加锁和解锁的次数,提高程序性能。

举一个简单的例子来说明锁粗化:假设有如下的代码片段

public void doSomeWork() {
    Object lock = new Object();

    synchronized(lock) {
        // 第一部分操作
    }

    synchronized(lock) {
        // 第二部分操作
    }
}

在这个例子中,两个 synchronized 块之间并没有其他的操作,它们都对同一个对象 lock 进行加锁和解锁。由于这两个 synchronized 块是连续的,并且作用于同一个对象,即时编译器可以进行锁粗化优化,将它们合并成一个更大的锁范围。

经过即时编译器的锁粗化优化后的代码可能会变成这样:

public void doSomeWork() {
    Object lock = new Object();

    synchronized(lock) {
        // 第一部分操作
        // 第二部分操作
    }
}

这样,原本分散的两个小的锁操作被合并成了一个大的锁范围,减少了加锁和解锁的次数,降低了性能开销。需要注意的是,锁粗化需要根据具体的代码结构和执行情况来进行判断,以避免因为粗化导致不必要的锁竞争或者锁持有时间过长而影响并发性能。

3.6 锁降级

锁降级是指在一个线程持有写锁的情况下,允许它在不释放当前的写锁的前提下获取读锁。这种操作被称为锁降级,因为它从持有更高级别的锁(写锁)降级到持有更低级别的锁(读锁)。

举个简单的例子来说明锁降级:假设有一个线程在执行一些需要独占资源的操作时先获取了写锁,然后在该操作过程中需要进行一些只读的操作。在传统的情况下,线程需要先释放写锁,然后再获取读锁,最后再执行只读操作。但是,通过锁降级,线程可以直接将当前的写锁降级为读锁,然后继续执行只读操作,从而避免了额外的锁释放和获取操作。

经过锁降级后的代码可能会类似于这样:

// 获取写锁
writeLock.lock();

// 执行需要独占资源的操作

// 锁降级为读锁
readLock.lock();
writeLock.unlock();

// 执行只读操作

// 释放读锁
readLock.unlock();

锁降级可以减少锁的竞争和开销,提高并发性能,但需要注意的是,在实际应用中需要非常小心地管理锁的升级和降级操作,以避免因为复杂的锁状态转换导致死锁或者并发安全性问题。

锁降级发生在哪里?

锁降级的发生通常是在多线程环境下对共享资源进行读写操作的过程中。

下面以Java中的ReentrantReadWriteLock为例说明锁降级的发生情况:

  1. 线程获取写锁:当一个线程需要独占资源执行写操作时,它会首先获取写锁。此时,其他线程无法获取读锁或写锁,因为写锁是独占的。
  2. 执行需要独占资源的操作:线程在持有写锁的情况下,执行一些需要独占资源的操作。
  3. 锁降级为读锁:在某个时刻,线程希望将当前的写锁降级为读锁,以允许其他线程同时读取共享资源。它会先获取读锁,然后再释放写锁。这样,线程就从持有写锁的状态降级到了持有读锁的状态。
  4. 执行只读操作:线程在持有读锁的情况下,执行一些只读操作。其他线程也可以同时获取读锁来读取共享资源。
  5. 释放读锁:线程在完成只读操作后,释放读锁。

在这个过程中,锁降级的发生点是步骤3,即将写锁降级为读锁。通过锁降级,线程可以在不释放当前的写锁的前提下获得读锁,从而实现了读写操作的并发执行。需要注意的是,在锁降级过程中,线程必须按照正确的顺序获取和释放锁,以避免死锁或并发安全性问题的发生。

3.7 及时编译器(Just-In-Time Compiler)

及时编译器(Just-In-Time Compiler,JIT Compiler)是一种将程序在运行时(而不是事先)编译成机器码的编译器。它通常用于解释型语言(如Java、C#等)的执行环境中,以提高程序的性能。

在解释型语言中,源代码通常会先经过解释器将其转换为中间代码,然后在运行时逐行解释执行。这种方式的好处是跨平台性好,可以将中间代码在不同的操作系统和硬件平台上运行。然而,由于解释执行的性能相对较低,因此引入了及时编译器来优化程序的性能。

当程序运行时,及时编译器会监视程序的运行情况,对热点代码(即频繁执行的代码路径)进行实时编译成机器码,以取代解释执行。这样做的好处是可以充分利用运行时的信息进行优化,比如内联函数调用、消除不必要的临时变量等,从而提高程序的执行速度。

总的来说,及时编译器通过将热点代码动态编译成机器码,结合解释器的灵活性,既保留了解释型语言的跨平台特性,又提高了程序的性能,是一种在解释型语言环境中常见的重要技术。

3.8 synchronized实现过程

当在Java中使用synchronized关键字时,它可以用于实例方法、静态方法或代码块。synchronized关键字的实现过程可以总结为以下几个步骤:

  1. 获取锁:当一个线程想要执行进入synchronized方法或代码块时,它会尝试获取对象的内置锁(也称为监视器锁(monitor))。如果该锁没有被其他线程持有,则该线程成功获取锁并继续执行。如果锁已经被其他线程持有,则该线程将被阻塞,直到锁可用为止。
  2. 执行同步代码:一旦线程成功获取锁,它就可以执行synchronized方法或代码块中的内容。只有获得锁的线程才能够执行同步代码,其他线程无法同时进入同步代码区域。
  3. 释放锁:当线程退出synchronized方法或代码块时,它会释放对象的内置锁,这样其他线程就有机会获取锁并执行同步代码。释放锁的操作是自动进行的,不需要显式地编写释放锁的代码。

通过这种方式,synchronized关键字确保了对共享资源的安全访问。只有一个线程能够获取锁并执行同步代码,其他线程必须等待锁的释放才能进入同步代码区域。这种机制避免了多个线程同时修改共享数据导致的并发问题,确保了线程安全性。

需要注意的是,synchronized关键字仅保证同一对象上的同步,不同对象之间的同步是独立的。因此,在多线程环境下,应选择正确的锁对象来实现所需的同步效果,以避免出现竞争条件和死锁等问题。

3.9 synchronized三种使用方式

当在Java中使用synchronized关键字时,它可以用于实例方法、静态方法或代码块。下面我将分别举例详细说明synchronized的实现过程。

  1. synchronized实例方法:
public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }
}

在上述例子中,我们有一个Counter类,其中有一个实例方法increment()用于递增计数器count的值。使用synchronized关键字修饰方法,即表明该方法是同步的。

当一个线程调用increment()方法时,它会尝试获得Counter对象的内置锁。如果锁可用,该线程将获得锁并执行方法体中的代码,此时其他线程无法进入该方法。当线程执行完毕或者发生异常时,它会释放锁,使其他线程有机会获取锁并执行方法体。

  1. synchronized静态方法:
public class Counter {
    private static int count = 0;

    public static synchronized void increment() {
        count++;
    }
}

在这个例子中,increment()方法被声明为静态方法,并使用synchronized关键字修饰。这意味着该方法是针对Counter类的所有实例共享的。

当一个线程调用increment()方法时,它会尝试获得Counter类的Class对象的内置锁。同样地,其他线程无法同时进入该方法,只有一个线程能够执行方法体中的代码。当线程执行完毕或者发生异常时,它会释放锁,使其他线程有机会获取锁并执行方法体。

  1. synchronized代码块:
public class Counter {
    private int count = 0;

    public void increment() {
        synchronized (this) {
            count++;
        }
    }
}

在这个例子中,increment()方法没有被synchronized修饰,但是使用了一个synchronized块来实现同步。

在synchronized块中,使用括号内的对象(这里是this)作为锁。当一个线程进入synchronized块时,它会尝试获得括号内对象的内置锁。其他线程无法同时进入具有相同锁对象的synchronized块,只有一个线程能够执行块中的代码。当线程执行完毕或者发生异常时,它会释放锁,使其他线程有机会获取锁并执行块中的代码。

通过上述例子,我们可以看到synchronized关键字的实现过程是通过获取和释放对象的内置锁来实现线程同步。这种机制确保了对共享资源的安全访问,避免了多个线程同时修改共享数据导致的并发问题。

内置锁提问

1. 是不是类中存在被synchronized修饰的地方,这个对象就有内置锁?

是的,当一个方法或代码块被 synchronized 关键字修饰时,它使用的是对象的内置锁。

对于一个非静态方法,使用 synchronized 修饰时,它将使用该方法所在对象的实例作为锁;

对于一个静态方法,使用 synchronized 修饰时,它将使用该方法所在类的 Class 对象作为锁。

如果在代码块中使用 synchronized (object) {} 的形式,其中 object 是一个对象引用,那么这个代码块也将使用 object 对象的内置锁。

因此,当方法或代码块被 synchronized 修饰时,它们就会使用对象的内置锁来确保同一时间只有一个线程能够进入被保护的代码区域,从而保证了共享资源的安全访问。

2. 为什么非静态方法,使用 synchronized 修饰时,它将使用该方法所在对象的实例作为锁;对于一个静态方法,使用 synchronized 修饰时,它将使用该方法所在类的 Class 对象作为锁?

这是因为在 Java 中,每个对象实例都有一个与之关联的内置锁,也称为互斥锁或监视器锁。当一个线程试图进入一个使用 synchronized 关键字修饰的方法或代码块时,它需要先获取该方法所在对象实例的内置锁,然后才能执行同步代码。

对于非静态方法来说,它是绑定到对象实例上的,因此使用 synchronized 修饰时,它将使用该方法所在对象实例的内置锁。这意味着不同的对象实例拥有各自独立的内置锁,因此一个线程可以同时访问多个不同对象实例的非静态 synchronized 方法,因为它们拥有不同的内置锁。

对于静态方法来说,它是属于类级别的,不依赖于对象实例,因此使用 synchronized 修饰时,它将使用该方法所在类的 Class 对象作为锁。这意味着无论有多少个该类的对象实例,它们共享同一个类级别的内置锁,因此在同一时间只有一个线程能够访问该静态 synchronized 方法,无论是哪个对象实例调用了该方法。

对于被修饰的代码块,使用 synchronized 修饰时会根据括号中的对象或表达式来确定所使用的锁。例如,如果使用 synchronized (this) {} 来修饰代码块,那么它将使用当前对象实例的内置锁;如果使用 synchronized (SomeObject) {} 来修饰代码块,那么它将使用 SomeObject 对象的内置锁。

因此,根据方法是属于对象实例还是类的,使用 synchronized 修饰时会分别使用对象实例的内置锁或类的内置锁来确保同步访问,以实现线程安全性。

四、volatile关键字

4.1 volatile作用

当在Java中使用volatile关键字时,它主要用于标记一个变量是"易失性"的,即可能被多个线程同时访问并修改。下面我将通过一个例子详细说明volatile关键字的作用和理解。

假设我们有以下代码片段:

public class VolatileExample {
    private volatile boolean flag = false;

    public void toggleFlag() {
        flag = !flag;
    }

    public void printFlag() {
        System.out.println("Flag is: " + flag);
    }
}

在这个例子中,flag变量被声明为volatile类型。现在让我们来详细说明volatile关键字的作用:

  1. 保证线程可见性:使用volatile关键字修饰的变量会告诉编译器和运行时环境,该变量是"易失性"的,不应该进行线程本地缓存。这意味着每次访问该变量时,都会直接从主内存中读取最新的值,而不是使用线程本地的缓存值。这确保了对volatile变量的写操作对其他线程是可见的,并且禁止了线程本地缓存,从而避免了由于线程间的可见性引起的并发问题。

  2. 禁止指令重排序:在多线程环境下,编译器和处理器可能对指令进行重排序优化,这在单线程环境下不会有影响,但在多线程环境下可能导致意想不到的结果。使用volatile关键字修饰的变量会禁止指令重排序,保证了程序的执行顺序与代码的顺序一致性。

  3. 不能保证原子性:volatile关键字能够保证对变量的写操作对其他线程是可见的,但并不能保证复合操作的原子性。如果一个操作依赖于变量的当前值,并且需要保证原子性,仍然需要使用synchronized关键字或者Lock等机制。

在上面的例子中,当一个线程调用toggleFlag()方法修改flag变量的值时,其他线程调用printFlag()方法能够立即看到flag的最新值,而不会出现延迟。

总之,volatile关键字确保了对变量的写操作对其他线程是可见的,并且禁止了线程本地缓存,从而避免了由于线程间的可见性引起的并发问题。然而需要注意的是,volatile并不能保证原子性,如果需要保证多个操作的原子性,仍然需要使用synchronized关键字或者Lock等机制。

4.2 单例设计模式

4.2.1 普通单例

单例模式是一种常见的设计模式,用于确保在整个应用程序中只存在一个特定类的实例,并提供全局访问点。

在Java中,实现单例模式的方法之一是使用私有构造函数和静态方法来返回类的唯一实例。以下是一个简单的示例:

public class Singleton {
    private static Singleton instance;

    // 私有构造函数,防止通过new关键字实例化该类
    private Singleton() { }

    // 静态方法返回类的唯一实例,如果实例不存在则创建新实例
    // 如果在多线程的情况下,无法保证单例,要加synchronized关键字,但是效率较慢
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
    
    public static void main(String[] args){
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();
        System.out.println(s1 == s2)	// true
    }
}

在这个示例中,Singleton类的构造函数被声明为私有的,因此其他类无法直接实例化它。getInstance方法是静态的,它负责返回Singleton类的唯一实例。如果实例尚不存在,则在第一次调用getInstance时创建它,随后的调用将返回已经存在的实例。

通过这种方式,我们可以确保在整个应用程序中只有一个Singleton类的实例,从而避免了不必要的资源消耗,并提供了一个全局访问点以便在需要时获取该实例。

4.2.2 双重检查单例(DCL单例)

为了在多线程环境下确保单例模式的正确性,可以使用双重检查锁定(Double-Checked Locking)来实现单例模式。双重检查锁定能够在第一次创建实例后,避免每次获取实例时都进行同步操作,从而提高性能。

以下是一个使用双重检查锁定实现单例模式的示例:

public class Singleton {
    // volatile关键字防止指令重排序
    private volatile static Singleton instance;
	// 私有构造函数,防止通过new关键字实例化该类
    private Singleton() { }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

在这个示例中,instance变量被声明为volatile,这可以确保多个线程正确处理instance变量。在getInstance方法中,首先检查instance是否为null,如果为null,则进入同步块并再次检查null,然后创建实例。通过这种方式,可以确保在多线程环境下也能正确地实现单例模式。

需要注意的是,虽然双重检查锁定可以在一定程度上提高性能,并且在Java 5及以后的版本中是有效的,但在一些早期版本的Java中可能存在一些问题,因此在使用双重检查锁定时需要注意Java版本的兼容性。

4.2.3 指令重排序

指令重排序是现代处理器为了提高运行效率而进行的一种优化手段,它会改变指令的执行顺序,但并不会影响最终的结果。然而,在多线程环境下,指令重排序可能会导致一些意想不到的问题。

举个例子来说明指令重排序可能引发的问题:

假设有如下代码片段:

public class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {         // 1: 线程A执行
            synchronized (Singleton.class) {
                if (instance == null) {   // 2: 线程B执行
                    instance = new Singleton();  // 3: 线程C执行
                }
            }
        }
        return instance;  // 4: 线程D执行
    }
}

在单线程环境下,以上代码能够正常工作。但是在多线程环境下,如果发生了指令重排序,可能会导致问题。具体来说,如果发生了以下的指令重排序:

  1. 线程A执行了步骤1,判断instance为null,然后进入同步块;
  2. 然后线程D执行了步骤4,发现instance仍然为null,于是返回了一个未完成初始化的instance
  3. 最后线程C执行了步骤3,完成了instance的初始化。

这样就导致了一个未完成初始化的instance被返回,违反了单例模式的初衷。

为了解决这个问题,可以使用volatile关键字修饰instance变量,它可以防止指令重排序,从而确保多线程环境下的安全访问。

4.2.4 volatile关键字是如何实现禁止指令重排序的?

对于volatile变量,Java内存模型会禁止指令重排序优化,具体实现方式如下:

  1. 内存屏障(Memory Barrier):在volatile变量的读写操作前后会插入内存屏障。内存屏障会确保在其前面的所有读写操作都完成后,才能执行内存屏障之后的读写操作。同样,内存屏障会阻止在其后面的读写操作被重排序到内存屏障之前。
  2. 内存屏障的实现:在不同的硬件架构或操作系统中,内存屏障的具体实现方式可能会有所不同。但通常会利用特定的机器指令来实现内存屏障,以确保内存操作的顺序性和可见性。
  3. 编译器优化:JIT编译器会识别volatile变量,并在生成的机器码中插入相应的内存屏障指令,以确保在运行时对volatile变量的读写操作符合内存模型的语义。

通过以上方式,volatile变量能够在多线程环境下禁止指令重排序,从而确保了变量的读写操作按照代码顺序执行,避免了潜在的并发访问问题。

  • 29
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java多线程高并发Java中非常重要的概念和技术,它们可以让Java程序更加高效地利用计算机多核CPU的性能,提升程序的并发能力,提高程序的性能和响应速度。 Java多线程机制是基于线程对象的,它允许程序同时执多个任务,从而提高程序的并发能力。Java中的线程有两种创建方式:继承Thread类和实现Runnable接口。其中继承Thread类是比较简单的一种方式,但是它会限制程序的扩展性和灵活性,因为Java只支持单继承。而实现Runnable接口则更加灵活,因为Java支持多实现。 Java高并发编程主要包括以下几个方面: 1. 线程池技术:线程池技术是Java中非常重要的高并发编程技术,它可以实现线程的复用,减少线程创建和销毁的开销,提高程序的性能和响应速度。 2. 锁机制:Java中的锁机制包括synchronized关键字、ReentrantLock锁、ReadWriteLock读写锁等,它们可以保证多个线程之间的互斥访问,避免数据竞争和线程安全问题。 3. CAS操作:CAS(Compare and Swap)操作是Java中一种非常高效的并发编程技术,它可以实现无锁并发访问,避免线程之间的互斥和阻塞,提高程序的性能和响应速度。 4. 并发集合类:Java中的并发集合类包括ConcurrentHashMap、ConcurrentLinkedQueue、CopyOnWriteArrayList等,它们可以实现线程安全的数据访问和操作,有效地提高程序的并发能力。 总之,Java多线程高并发Java编程中非常重要的概念和技术,掌握这些技术可以让Java程序更加高效地利用计算机多核CPU的性能,提升程序的并发能力,提高程序的性能和响应速度。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值