java学习记录16

并发基础

进程与线程

进程

进程(Process)是计算机中正在运行的程序。程序是一种静态的概念,而进程是程序在执行过程中创建的动态实体。每个进程都有自己的内存空间、代码、数据和资源,它也是操作系统进行任务调度和资源分配的基本单位。

也可以这样理解,进程是正在运行一个软件或者脚本。

多个进程可以同时运行在计算机上,彼此独立并且互不干扰。操作系统通过进程管理来控制和监视进程的创建、运行、暂停和终止等操作。进程还可以通过进程间通信机制来实现进程之间的数据交换和同步。

 打开任务管理器,运行的程序就是一个个进程。

线程 

线程(Thread)是程序执行的最小单位,它是进程的一部分。一个进程可以包含一个或多个线程。同一个进程共享同一块内存空间和其他资源,它们可以同时执行不同的任务。每个线程都有自己的程序计数器、栈和一组寄存器,这使得线程能够独立地执行代码。

线程的特点

  • 轻量级:相对于进程来说,线程的创建和上下文切换开销较小。
  • 共享资源:同一个进程中的线程可以共享内存和其他资源,因此可以更方便地进行数据共享和通信。
  • 并发执行:多个线程可以同时执行,提高了程序的并发性和效率。

 线程的使用可以提升程序的性能和响应性,特别是在多核处理器上可以实现并行计算。但多线程编程也需要注意线程间的同步和共享数据的安全性问题,以避免出现条件和数据不一致的情况。

线程的时间效率和空间效率都比进程要高。

并行与并发

并行

并行(Parallel)是指同时进行多个任务,任务之间可以同时执行,彼此独立。例如,在多核处理器上,可以同时执行多个线程或进程。

并发

并发(Concurrent)是指同时交替进行多个任务,任务之间可能有依赖关系或竞争条件。例如,在单核处理器上,通过时间片轮转的方式,多个线程或进程通过快速切换执行,看起来是同时进行的,但实际上是交替执行的,并发中的任务可能需要依赖共享资源或竞争临界区资源。

并行是多个任务同时进行,而并发是多个任务交替进行,并且可能存在资源竞争和依赖。

并行和并发之间的区别在于任务是否可以同时执行以及是否需要竞争共享资源。并行通常需要硬件支持,如多核处理器,能同时执行多个任务。而并发则可以在单核处理器上通过时间片轮转等计数实现。

在实际应用中,可以用并行提高计算性能和执行速度,可适用于多线程编程或分布式计算;而并发则可以提高资源利用率和系统吞吐量,可适用于任务调度和资源管理。

多线程的必要性

CPU、内存(主存)、I/O(读写),这三者的处理速度有着极大的差异。为了平衡这三者的速度差异,需要在计算机体系结构、操作系统、编译程序上进行优化。

CPU缓存优化

  • CPU(中央处理器)是计算机的核心软件,负责处理大部分的计算任务。然而,CPU处理数据的速度远高于内存读取或写入数据的速度,这种速度上的差异就会导致CPU在等待数据时无事可做,从而浪费其处理能力。
  • 为了解决这个问题,专门在CPU和内存之间增加了缓存(Cache)作为一个数据的临时存储区,用于存放CPU预期会用到的数据。因为缓存是位于CPU和内存之间的临时存储区,它的存取速度比内存要快得多,所以能够有效地解决CPU和内存速度上的差异。
  • 当CPU需要读取或写入数据时,它会首先查找缓存中是否有这些数据。如果有(这称为”缓存命中“),CPU就可以之间从缓存读取或写入数据,从而避免了等待内存的消耗时间。如果没有(这称为”缓存未命中“),CPU就需要从内存中读取数据,并同时将这些数据写入缓存,以供后续使用。
  • 通过这种方式,缓存能够有效地利用CPU的高速处理能力,提高计算机的整体性能。

CPU增加缓存,会导致可见性问题。

操作系统优化 

操作系统增加了进程和线程的概念来实现分时复用CPU资源,进而均衡CPU和I/O设备的速度差异。

  • 进程是指计算机中正在运行的程序的实例。操作系统通过为每个进程分配一段独立的内存空间和一组资源(如CPU时间片、文件描述符等)来管理并控制进程的运行。通过轮流分配CPU时间片,操作系统可以让多个进程交替运行,从而实现了CPU的分时复用。
  • 线程是指进程中的一个独立执行单元。一个进程可以有多个线程,它们共享该进程的资源和状态。每个线程都有自己的栈空间和程序计数器,但它们共享同一进程的内存空间、文件描述符等。操作系统可以通过调度算法在不同的线程之间切换,从而实现多个线程在单个进程中并发执行。
  • 通过引入进程和线程的概念,操作系统可以将CPU时间片分配给不同的进程,实现进程之间的轮流执行并提高CPU的利用率。同时,操作系统可以通过线程的并发执行来隐藏I/O设备操作的等待时间,提高系统的响应速度。
  • 操作系统通过增加进程和线程的概念,实现了分时复用CPU资源,使得多个进程和线程可以并发执行,从而在CPU和I/O设备的速度差异中实现了均衡。

操作系统增加了进程、线程,会导致原子性问题。 

编译程序优化 

编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

  • 编译程序优化指令执行次序是指在编译过程中,编译器根据各种优化策略和规则,自动调整和重排程序中指令的顺序,以提高程序地运行效率和性能。
  • 编译器进行指令优化的主要目的是为了解决一下问题:
  • 硬件资源利用:通过优化调整指令次序,可以更好地利用CPU的并行性,提高CPU利用率,减少资源浪费。例如,通过指令调度让CPU同时执行多条无关的指令,从而提高指令的并行等级。
  • 减少等待时间:某些指令执行需要等待前面指令的结果,这回导致CPU空闲等待。通过优化指令顺序,可以尽可能地避免这种依赖,减少CPU空闲时间,提高运行效率。
  • 克服性能瓶颈:例如,优化内存访问指令地顺序,可以减少缓冲区溢出或下溢地可能性,避免因为内存访问导致的性能瓶颈。
  • 管线浪费:现代CPU通常将指令执行过程分解成多个阶段,并行执行以提高性能,这就是所谓地管线技术。如果指令地执行顺序不能很好地匹配CPU管线,就会导致管线阶段地闲置,造成性能下降。

通过这些优化,编译器可以帮助程序员在不需要手动干预地情况下自动提高程序地运行效率和性能,使得程序在各种硬件平台上都能获得更好的运行效果。

编译程序优化指令执行次序,会导致有序性问题。

线程安全问题

如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。

以下代码演示了1000个线程同时对cnt执行自增操作,操作结束之后它的值有可能小于1000。

public class ThreadUnsafeExample {

    private int cnt = 0;

    public void add() {
        cnt++;
    }

    public int get() {
        return cnt;
    }
}

public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    ThreadUnsafeExample example = new ThreadUnsafeExample();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}

// 输出结果:992

为什么多线程操作会导致结果小于1000,是什么导致了线程安全问题?

导致多线程安全问题的主要原因有三个方面:CPU缓存导致的可见性问题、进程或线程导致的原子性问题、编译器优化导致的有序性问题。

线程安全导致原因

多线程操作共享变量时,才会引起线程安全问题。

可见性 

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。

CPU缓存会导致可见性规则打破,举例:

//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;
  • 假如执行线程1的是CPU1,执行线程2的是CPU2.。当线程1执行 i=10 这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
  • 此时线程2执行 j=i, 它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中的i值还是0,那么就会使得j的值为0,而不是10。

 线程1对变量i修改了之后,线程2没有立即看到线程1修改的值,所以CPU缓存会导致可见性问题。

原子性

原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

分时复用会引起原子性打破,举例:

int i = 1;

// 线程1执行
i += 1;

// 线程2执行
i += 1;

这里需要注意的是: i+=1 需要三条CPU指令

  • 将变量i从内存中读取到CPU寄存器;
  • 在CPU寄存器中执行 i+1 操作;
  • 将最后的结果i写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。

由于CPU分时复用(线程切换)的存在,线程1执行了第一条指令后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换回线程1执行后续两条指令,将造成最后写到内存中的i值是2而不是3。所以CPU分时复用会导致原子性问题。

有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。

重排序优化会打破程序的有序性,举例:

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

虽然上述代码语句2在语句1之后,但是多线程下执行顺序不一定语句1、语句2这个顺序执行。重排序是指计算机系统(如处理器、编译器)在执行程序时,按照某种规则重新调整指令或操作的顺序,以提高程序性能或满足其他需求。

 重排序可以分为三种类型:编译器重排序、处理器重排序和内存系统重排序。

  • 编译器重排序: 在编译阶段,编译器可能会对源代码中的指令重新排序、以优化代码的执行效率。编译器重排序不会改变程序的语义,即保证最终的执行结果与源代码的顺序一致。编译器重排序可以通过指令级并行、循环展开、常量传播等技术来实现。
  • 处理器重排序:在处理器执行指令时,由于处理器采用了流水线技术,它可以对指令进行重排序、以尽可能地利用处理器资源。处理器重排序可能包括指令级重排序(乱序执行)和内存访问重排序。指令级重排序是指处理器可以改变指令地执行顺序,以提高指令地并行度和执行效率。内存访问重排序是指处理器可以改变对内存地读写操作的顺序,以充分利用内存系统的各级缓存。
  • 内存系统重排序:由于现代计算机系统中存在多级缓存、总线和内存等层次结构,由此对于内存的读写操作也可能存在重排序。内存系统重排序可以通过缓存一致性协议和写缓冲区等技术实现。

重排序在一定程度上可以提高程序的执行速度和效率,但必须在确保程序正确性和语义一致性的前提下进行。在并发编程中 ,重排序可能会引发数据竞争、原子性问题等多线程并发问题,因此需要采取同步和内存屏幕等手段进行控制和保护。

编译程序优化的重排序下,程序的有序性会打破。所以编译器优化和处理器重排序可能会导致有序性问题。

Java重排序流程:

1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

对于处理器排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers, intel 称之为 fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

Java解决并发问题

Java需要解决多线程并发安全问题,就需要解决可见性、有序性、原子性这三个问题。

JMM(java内存模型)规范了 JVM如何按需禁用缓存和编译优化的方法。

volatile、synchronized 和 final 关键字

volatile

volatile是Java提供的一种轻量级的同步机制:

保证共享变量的可见性:当一个线程修改了volatile变量的值,新值对于其他线程来说是可以立即得知的。也就是说,volatile变量在各个线程中是一致的,这就是所谓的可见性。

禁止指令重排序:有些场合下,为了提高性能,编译器和处理器可能会对输入代码进行优化,它们会把存在数据依赖关系的操作,重新进行排序。声明为volatile的变量,编译器和处理器就不会对其进行重排序。

不提供原子性:虽然volatile变量能够保证可见性和有序性,但没办法保证复合操作的原子性。例如,num++这样的操作,其实包含了多个子操作,包括:读取原有的值、进行加1操作、将新值写回到内存中。这三个子操作并不是原子性的,也并不会因为volatile声明而变成原子操作。因此,若需要保证原子性,通常需要结合synchronized或者Atomic变量来使用。

synchronized 

synchronized关键字在Java中被用来作为一种同步锁:

保证线程安全:synchronized可以修饰方法或者以同步代码块的形式来修饰代码段,能够保证在同一时刻最多只有一个线程执行该代码,从而保证了类实例的成员变量的线程安全。

保证可见性和有序性:synchronized可以保证被其修饰的变量的修改能够及时地被其他线程看到,从而避免了被其他线程看到,从而避免出现数据不一致地情况。此外,其还能够保证线程的执行是有序的,防止出现指令重排的情况。

锁的释放与获取:包括以下三种情况会释放锁。一是当前线程执行完同步代码就会释放掉锁。二是如果线程执行同步代码块的过程中,出现了异常且异常被捕获,也会导致锁的释放。三是当前线程在执行同步代码块的过程中执行了锁所属的对象的wait()方法,这也会导致线程释放掉锁。

final

final保证不可修改,不变的内容不会引起多线程的安全问题:

final修饰变量:final修饰的变量表示常量,它的值不能被修改。一旦赋值后,就不能再改变。常量一般使用大写字母表示,并使用下划线分割单词。

final修饰方法:final修饰的方法不能被子类重写。这种方法在继承关系中起到了保护作用,可以确保父类的方法行为不被子类修改。

final修饰类:final修饰的类不能被继承,即不能有子类继承该类。这样的类通常是不希望被修改和扩展的最终版本。

final修饰参数:final修饰方法的参数:表示该参数在方法内部不可修改。这可以用来保护方法内部的参数不被意外改变。

可见性、有序性、原子性的理解

可见性

Java提供了volatile关键字来保证可见性。

volatile关键字可以保证共享变量的可见性。当一个共享变量被volatile修饰式,它的值的修改会立即被更新到主存中,当其他线程需要读取该共享变量时,它会去主存中获取最新的值。相比之下,普通的共享变量是不能保证可见性,因为其修改的值可能会延迟写入主存,当其他线程需要读取时,可能得到的仍然是旧值,从而无法保证可见性。

通过synchronized和Lock保证可见性。

通过synchronized和Lock也能够保证可见性,sychronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前将会对变量的修改刷新到主存当中。因此可以保证可见性。

有序性

在Java中,使用volatile关键字可以确保一定的有序性。此外,也可以使用synchronized和Lock来确保有序性。显然,synchronized和Lock保证同步代码每次只有一个线程执行,这相当于让线程按顺序执行同步代码,自然而然地保证了有序性。当然,Java内存模型(JMM)通过Happens-Before规则来保证有序性。 

 原子性

在Java中,对基本数据类型的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

x = 10;        //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x;         //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
x++;           //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
x = x + 1;     //语句4: 同语句3

也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

从上面的例子中可知,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任意时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

Happens-Before规则:

  • 程序顺序规则(Program Order Rule):在同一个线程中,按照程序的顺序,前一个操作的结果对于后续操作是可见的。换句话说,线程中的操作按照代码顺序执行。
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作对于后续的lock操作是可见的。之前已经释放的锁,之后的加锁操作可以感知到。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作,对于后续该变量的读操作是可见的。volatile关键字会禁止指令重排,保证了写操作的可见性。
  • 线程启动规则(Thread Start Rule):一个线程的start操作对于其他线程中的后续操作是可见的。换句话说,其他线程可以看到线程启动之后的操作。
  • 中断规则(Thread Interrupt Rule):一个线程中断的发生(调用interrupt方法),对于该线程的后续操作是可见的。
  • 线程终结规则(Thread Termination Rule):主线程的所有操作对于所有已经假如到该线程的子线程的join操作是可见的。换句话说,主线程的操作对于子线程的join操作是可见的。
  • 线程中断规则(Thread Interruption Rule):对于在线程A中调用线程B的interrupt方法,如果线程B捕获到该中断,则线程A的所有操作对于线程B捕获中断之后的操作是可见的。
  • 对象终结规则(Finalizer Rule):一个对象的构造函数完成对该对象的初始化后,对于finalilze方法的调用是可见的。

线程安全调度

线程安全可以从强到弱分为以下几个级别: 不可变、绝对线程安全、相对线程安全、线程兼容、线程对立。

不可变

不可变对象是线程安全的,因为它们的状态在创建后不可更改。多个线程可以同时访问和使用不可变对象,而无需任何同步控制。

  • final关键字修饰的基本数据类型。
  • String
  • 枚举类型。
  • Number部分子类,如Long和Double等数值包装类型,BigInteger和BigDecimal等大数据类型。但同为Number的原子类AtomicInteger和AtomicLong则是可变的。

绝对线程安全 

绝对线程安全意味着对象的所有方法都是线程安全的,可以多线程并发地访问和修改对下给你,而不需要额外地同步控制。这通常是通过使用同步机制(如synchroized关键字或使用Lock接口)或线程安全的数据结构来实现的。

相对线程安全

  • 相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
  • 在Java中,大部分的线程安全类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection() 方法包装的集合等。

线程兼容

  • 线程兼容意味着对象在单线程环境中是安全的,但在多线程环境中可能会有问题。在多线程环境中访问和修改该对象时,可能需要使用同步控制来确保多个线程之间的访问和修改顺序。这种级别通常需要开发人员来注意使用同步机制。
  • Java API中大部分的类都是属于线程兼容的,如与前面的Vector 和 HashTable相对应的集合类ArrayList和HashMap等。

 线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

线程安全实现

互斥同步

互斥同步是一种保证多个线程在访问共享资源时的互斥性的机制,以防止竞态条件和数据不一致问题。

核心

加锁:synchronized 和 ReentrantLock。

互斥同步的理解:

  • 互斥(Mutual exclusion):互斥是指同一时刻只允许一个线程访问共享资源,其他线程必须等待。互斥机制可以保证在任何时刻只能有一个线程对共享资源进行操作。
  • 临界区(Critical section):临界区是指一段代码,其中访问共享资源的部分。在进入临界区前,线程需要获得互斥锁,执行完临界区代码后释放互斥锁,也就是说只有获得互斥锁的线程才能进入临界区。
  • 互斥锁(Mutex):互斥锁是一种同步机制,用于保护临界区的访问。在进入临界区之前,线程必须获取互斥锁,如果互斥锁已经被其他线程持有,则请求线程会被阻塞,直到互斥锁被释放为止。一旦线程获得了互斥锁,其他线程将无法获得该锁,知道它被释放。
  • 条件变量(Condition variable):条件变量是一种同步进制,用于在共享资源的状态发生变化时线程的等待和唤醒。条件变量通常与互斥锁一起使用。当某个线程发现共享资源的状态不满足其要求时,它会进入等待状态,同时释放互斥锁,允许其他线程继续执行。当其他线程更改了共享资源的状态并满足该线程的要求时,它会被唤醒,并重新获取互斥锁。

互斥同步的主要问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。

非阻塞同步 

阻塞同步采用的是悲观策略,认为一个线程在修改时,一定会有其他线程进行访问修改,导致数据不一致。

非阻塞同步采用的是乐观策略,认为一个线程在修改时,不会有其他线程进行访问修改,那就修改成功了,如果有其他线程修改,那就采取补偿措施(不断地重试,直到成功为止)。一个线程在进行某段特定代码(临界区)操作时,其他线程可以进行其他代码的操作,不需要进行等待。

CAS

CAS(Compare And Swap)适用于解决多线程环境下的并发问题的一个方案(非阻塞同步方案),保证原子性。

通过比较一个内存位置的值,如果相等,则将新值写入该内存位置,否则不做任何操作。

CAS操作包括三个操作数:内存位置(内存地址)V、预期值A和新值B。具体操作步骤如下:

  • 将内存位置的当前值(即预期值)V与预期值A进行比较。
  • 如果相等,则将新值B写入内存位置,操作成功。
  • 如果不相等,则表示内存位置的值已经被其他线程修改,操作失败。根据需要可以重试或者执行其他处理逻辑。 

CAS的特点和优势包括:

  • 原子性:CAS操作是原子操作,保证了操作的完整性。在操作中,其他线程不能修改内存位置的值,因此可以确保数据的一致性。
  • 无锁:相比于使用锁进行同步,CAS是一种无锁的方式。它避免了线程阻塞和上下文切换带来的开销,在高并发的情况下性能较好。
  • 忙等待:由于CAS是基于自旋的方式进行操作,当操作失败时,线程会忙等待直到操作成功。这可能会造成一定的CPU开销。
  • 无阻塞:由于CAS不涉及线程阻塞,因此不存在死锁问题。

CAS主要应用于一些需要高并发和原子性操作的场景,比如非阻塞算法、无所对列和乐观锁等。在Java中,java.util.concurrent.atomic 包提供了一些原子类,如Atomicinteger和AtomicLong,它们底层使用了CAS来实现线程安全的操作。

ABA

如果一个变量初次读取的时候是A值,它的值被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有改变过。

在使用CAS时可能存在ABA问题,也就是说即使内存位置的值已经变化,但其实际含义对当前线程来说是没有变化的。为了解决ABA问题,可以使用版本号或引用的方式进行解决。比如AtomicStampedReference和AtomicMarkableReference类。

无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无需任何同步措施去保证正确性。

栈封闭 

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

public class StackClosedExample {
    public void add100() {
        int cnt = 0;
        for (int i = 0; i < 100; i++) {
            cnt++;
        }
        System.out.println(cnt);
    }
}

public static void main(String[] args) {
    StackClosedExample example = new StackClosedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> example.add100());
    executorService.execute(() -> example.add100());
    executorService.shutdown();
}

/**
 * 输出结果:
 * 100
 * 100
 */

线程本地存储

如果能保证共享变量每次使用时,都在同一个线程中执行,那么就没有线程安全问题可言。

可重入代码

 可重入代码(reentrant code)是指可以由多个任务并发使用,且不会引发数据错误的代码。换言之,一个可重入的程序、函数或例程在执行过程中被中断,然后在中断返回前再次调用,它都将产生可预期的结果。

可重入性是一个重要的概念,尤其是在多线程或多任务的并发编程环境中,它确保了代码的执行不会被其他线程或任务的干扰。

要编写可重入代码,就必须避免使用全局变量、静态变量或其他非局部的状态,也需要避免调用非重入的函数,并确保对互斥对象的访问(如锁)是正确的。

线程基础

线程状态

线程的状态包含

新建(New)、可运行(Runnable)、阻塞(Blocking)、无限期等待(Waiting)、限期等待(Timed Waiting)、死亡(Terminated)。

新建(New)

当新创建一个线程对象时,线程即进入新建状态。此时,该线程还没有开始运行,没有分配到CPU时间片。

可运行(Runnable)

当调用线程的start() 方法后,线程即进入运行状态。此时,该线程已经分配到了CPU时间片,并且开始执行。

阻塞(Blocking)

线程阻塞是指线程在执行过程中暂停或等待某个条件满足之后才能继续执行的状态。等待获取一个排他锁,如果其线程释放了锁就会结束此状态。

线程阻塞可能发生在以下几种情况:

  • IO阻塞:线程在执行IO操作(如读写文件、网络通信等)时,如果IO操作未完成或数据未就绪,线程会被阻塞,暂停执行,直到IO操作完成或数据就绪。
  • 等待阻塞:线程调用了wait()方法,进入等待状态,直到其他线程通过notify()或notifyAll()方法唤醒它。
  • 睡眠阻塞:线程调用了sleep()方法,使自身进入了睡眠状态,暂停执行指定的时间。
  • 锁阻塞:线程在执行同步代码块或同步方法时,如果获取不到同步锁(即锁已被其他线程持有),线程会被阻塞,直到获得锁。
  • 运行阻塞:处于运行状态的线程可以被调度器暂停执行,转而执行其他线程。

在线程阻塞的状态下,线程会暂停执行,不会占用CPU资源。一旦解除阻塞条件,线程会从阻塞状态恢复,重新进入可运行状态,等待CPU调度执行。

无限期等待(Waiting)

等待其他线程显式地唤醒,否则不会被分配CPU时间片。

进入方式退出方式
没有设置Timeout 参数的Object.wait()方法Object.notify() / Object.notifyAll()
没有设置Timeout参数的Thread.join()方法被调用的线程执行完毕
LockSupport.part()方法LockSupport.unpart()方法

 限期等待(Timed Waiting)

无需等待其他线程显示地唤醒,在一定时间之后会被系统自动唤醒。

  • 调用Thread.sleep() 方法使线程进入限期等待状态时,常常用使一个线程睡眠进行描述。
  • 调用Object.wait()方法使线程进入限期等待或者无限期等待时,常常用挂起一个线程进行描述。
  • 睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。

进入方式退出方式
Thread.sleep()方法时间结束
设置了Timeout参数的Object.wait()方法时间结束/Object.notify()/Object.notifyAll()
设置了Timeout参数的Thread.join()方法时间结束/被调用的线程执行结束
LockSupport.parkNanos()方法
LockSupport.parkUntil()方法

阻塞和等待的区别

阻塞状态(Blocked):线程阻塞是在某些特定情况下发生,比如线程在执行同步代码块或方法时,尝试获取一个已经被其他线程持有的锁,或者在执行IO操作时等待数据完成。线程阻塞是由外部因素(如锁、IO操作等)所引起的,线程会暂停执行,直到满足阻塞条件才会继续执行。

等待状态(Waiting):线程等待是通过调用对象的wait()方法来实现的,线程进入等待状态时,它会主动释放所持有的对象锁,进入等待对列中,直到其他线程调用notify()或notifyAll()方法唤醒等待的线程。线程等待是与其他线程之间的协作关系,等待关系的线程需要通过其他线程的通知来唤醒。

阻塞状态是由外部因素被动引起的,线程会暂停执行,并在满足某个条件后继续执行。而等待状态是线程主动释放对象锁,并进入等待状态,直到其他线程通知其继续执行。 

死亡(Terminated) 

线程执行完了其任务或者出现了异常,即进入死亡状态。

线程创建使用

线程的创建有三种方式:实现Runnable接口、实现Callable接口、继承Thread类。其实还有一种线程池创建线程。

实现Runnable 和 Callable接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过Thread来调用。可以说任务是通过线程驱动从而执行的。也就是实现Runnable 和 Callable 接口的类创建的对象,这个对象最终还是作为参数给到Thread 来驱动执行。

 实现Runnable接口

实现Runnable接口,并重写run()方法,然后将其创建为Thread对象,调用Thread对象的start()方法来启动。

public class MyRunnable implements Runnable {
    public void run() {
        // ...
    }
}

public static void main(String[] args) {
    MyRunnable instance = new MyRunnable();
    Thread thread = new Thread(instance);
    thread.start();
}

实现Callable接口

与Runnable相似,但Callable可以有返回值,返回值通过FutureTask进行封装。

public class MyCallable implements Callable<Integer> {
    public Integer call() {
        return 123;
    }
}

public static void main(String[] args) throws ExecutionException, InterruptedException {
    MyCallable mc = new MyCallable();
    FutureTask<Integer> ft = new FutureTask<>(mc);
    Thread thread = new Thread(ft);
    thread.start();
    System.out.println(ft.get());
}

继承Thread类

创建MyThread类继承Thread,并重写run()方法,然后将其创建为Thread对象,调用Thread对象的start()方法来启动。

public class MyThread extends Thread {
    public void run() {
        // ...
    }
}

public static void main(String[] args) {
    MyThread mt = new MyThread();
    mt.start();
}

当调用start()方法启动一个线程时,虚拟机会将该线程放入就绪对列中等待被调度,当一个线程被调度时会执行该线程的run()方法。

实现接口、继承Thread类对比

大多数情况下,实现Runnable接口会更好一些。

  • java不支持多重继承,如果一个类已经继承了其他类,就不能再继承Thread类了,只能通过实现接口来创建线程。
  • 类可能只要求可执行就行,继承整个Thread类开销过大。
  • 实现接口的方式更符合面向对象的设计思想。

线程机制

基础的线程机制需要理解:Executor、Daemon、sleep()、yield()。 

Executor

从线程创建使用,我们知道了怎么创建和启动一个线程,但是实际的开发过程中,如果用到了多线程,线程不可能只有一个,如果有很多个线程,一个线程一个线程的创建启动,那样会非常难以管理。于是引入了Executor(线程执行器)。

  • Executor框架是Java中用于执行任务的高级框架(线程池框架),通过使用线程池和任务调度策略,简化了线程的使用和管理,提供了更灵活、可拓展的任务执行方式。
  • Executor管理多个异步任务的执行,而无需程序员显式地管理线程地声明周期。这里的异步是指多个任务的执行互补干扰,不需要进行同步操作。

 Executor继承关系

Executors类是Java中Executor框架的工具类,它提供了一些静态方法来创建不同类型的Executor实例。

 Executors中几种常用的方法创建Executor:

方法说明
newFixedThreadPool(int nThreads)创建一个固定大小的线程池,该线程池的线程数量固定为指定的n Threads个。任务会被顺序执行,多于的任务会在任务对列中等待。返回一个ThreadPoolExecutor实例。
newCachedThreadPool()创建一个可缓存的线程池,线程池的大小会根据需要进行调整,没有活动的线程会被回收,需要执行任务时会自动创建新的线程。返回一个ThreadPoolExecutor实例。
newSingleThreadExecutor()创建一个单线程的线程池,该线程池中只有一个线程,所有任务按照提交的顺序依次执行。返回一个ThreadPoolExecutor实例。
newFixedThreadPool(int nThreads, ThreadFactory threadFactory)创建一个固定大小的线程池,并可以设置自定义的线程工厂。线程池中的线程数量固定为指定的n Threads个。任务会被顺序执行,多余的任务会在任务对列中等待。返回一个ThreadPoolExecutor实例。
newScheduledThreadPool(int corePoolSize)创建一个定时执行任务的线程池,线程池的大小固定为指定的corePoolSize个。该线程池可按照设定的时间间隔或特定的执行时间来执行任务。返回一个ScheduledThreadPoolExecutor实例。
public static void main(String[] args) {
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < 5; i++) {
        executorService.execute(new MyRunnable());
    }
    executorService.shutdown();
}

Daemon

守护线程(Daemon Thread)是一种特殊的线程,它的特性是进程结束时,所有的守护线程也会随之结束。

Java中几种常见的线程分类:

  • 用户线程(User Threads)):用户线程是应用程序中最常见的线程类型,由应用程序创建和控制。它们执行应用程序的业务逻辑和任务,当所有的用户线程执行完毕后,JVM就会退出。
  • 后台线程(Daemon Threads)又称为守护线程:后台线程是一种特殊的线程,它在程序运行期间在后台提供服务。后台线程的声明周期不会影响程序的退出,当所有用户线程结束时,JVM会自动终止所有未结束的后台线程。后台线程通常用于执行一些不需要阻止应用程序退出的任务,如垃圾回收(GC)。
  • 主线程(Main Thread):主线程是应用程序启动时第一个被创建的线程,它执行的是main()方法。主线程通常负责初始化应用程序的环境、加载类和启动其他线程。一旦主线程执行完毕,程序可能继续执行其他用户线程或退出。

 守护线程不会阻止JVM(Java虚拟机)或者其他主程序退出

在JVM中,当所有的非守护线程(也就是用户线程)都退出时,那么JVM就认为程序已经结束了,因此会结束运行。这时,JVM不会关心是否还有守护线程在运行,无论守护线程是否完成任务,它们都会被强制结束。因此“守护线程不会阻止JVM或者主程序退出”。

 将用户线程设置为守护线程:

public static void main(String[] args) {
    Thread thread = new Thread(new MyRunnable());
    thread.setDaemon(true);
}

sleep()

Thread.sleep(millisec)方法会休眠当前正在执行的线程,millisec单位为毫秒。

sleep() 可能会抛出InterruptedException,因为异常不能跨线程传播回main()中,因此必须在本地进行处理。线程中抛出的其他异常也同样需要在本地进行处理。

public void run() {
    try {
        Thread.sleep(3000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

yield()

对静态方法Thread.yield()的调用声明了当前线程已经完成了声明周期中最重要的部分,可以切换给其他线程来执行。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其他线程可以运行。

public void run() {
    Thread.yield();
}

线程中断

Thread中提供了中断相关方法:

方法说明
void interrupt()中断线程,即设置线程的中断状态为true。
boolean isInterrupt()判断线程是否被中断,如果线程被中断,返回true;否则,返回false。
static boolean interrupted()判断当前线程是否被中断,如果线程被中断,返回true并清除中断状态;否则,返回false。
  • 当想中断某个线程时,可以调用那个线程的interrrupt()方法,此时那个线程的中断标志位会被设置为true。被中断的线程可以通过isInterrupted()来检查自身是否被中断。如果需要清除中断标志位,可以使用interrupted()方法,它会返回当前的中断状态,并立即清除该状态(设为false)。
  • 通过调用一个线程的interrupt()来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出InterruptedException,从而提前结束该线程。但是不能中断I/O阻塞和synchronized锁阻塞。

InterruptedException案例

 在main()中启用一个线程之后再中断它,由于线程中调用了Thread.sleep()方法,因此会抛出一个InterruptedException,从而提前结束线程,不执行之后的语句。

public class InterruptExample {
    private static class MyThread1 extends Thread {
        @Override
        public void run() {
            try {
                Thread.sleep(2000);
                System.out.println("Thread run");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new MyThread1();
        thread1.start();
        thread1.interrupt();
        System.out.println("Main run");
    }
}

// 异常结果:java.lang.InterruptedException: sleep interrupted

interrupt案例

public class InterruptExample {

    private static class MyThread2 extends Thread {
        @Override
        public void run() {
            while (!interrupted()) {
                // ..
            }
            System.out.println("Thread end");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread2 = new MyThread2();
        thread2.start();
        thread2.interrupt();
    }
}

// 结果:Thread end

Executor的中断

调用Executor的shutdown()方法会等待线程都执行完毕之后再关闭。但是如果调用的是shutdownNow()方法,则相当于调用每个线程的interrupt()方法。

public static void main(String[] args) {
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> {
        try {
            Thread.sleep(2000);
            System.out.println("Thread run");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    executorService.shutdownNow();
    System.out.println("Main run");
}

如果只想中断Executor中的一个线程,可以通过使用submit()方法来提交一个线程,它会返回一个Future<?>对象,通过调用该对象的cancel(true)方法就可以中断线程

Future<?> future = executorService.submit(() -> {
    // ..
});
future.cancel(true);

互斥同步

Java提供了两种锁机制来控制多个线程对共享资源的互斥访问。第一个JVM实现的sychronized,而另一个是JDK实现的ReentrantLock。

synchronized

synchronized同步范围和位置有关:同步代码块、同步方法、同步类、同步静态方法。

同步代码块

同步代码块只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步。

使用ExecutorService执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句块时,另一个线程就必须等待。

public class SynchronizedExample {

    public void func1() {
        synchronized (this) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedExample e1 = new SynchronizedExample();
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.execute(() -> e1.func1());
        executorService.execute(() -> e1.func1());
    }
}

// 输出结果:0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

两个线程调用了不同对象的同步代码块,因此这两个线程就不需要同步。从输出结果可以看出,两个线程交叉执行。

public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    SynchronizedExample e2 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e2.func1());
}

// 输出结果:0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9

同步方法

效果和同步代码块一样。

public synchronized void func () {
    // ...
}

同步类

作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。

public class SynchronizedExample {

    public void func2() {
        synchronized (SynchronizedExample.class) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedExample e1 = new SynchronizedExample();
        SynchronizedExample e2 = new SynchronizedExample();
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.execute(() -> e1.func2());
        executorService.execute(() -> e2.func2());
    }
}

// 输出结果:0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

同步静态方法

效果和同步类一样,作用于整个类。

public synchronized static void fun() {
    // ...
}

ReentrantLock

ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。

public class LockExample {

    private Lock lock = new ReentrantLock();

    public void func() {
        lock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        } finally {
            lock.unlock(); // 确保释放锁,从而避免发生死锁。
        }
    }

    public static void main(String[] args) {
        LockExample lockExample = new LockExample();
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.execute(() -> lockExample.func());
        executorService.execute(() -> lockExample.func());
    }
}

// 输出结果:0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

synchronzied、ReentrantLock对比

  • 锁的实现:sychronized 是JVM实现的,而ReentrantLock是JDK实现的。
  • 性能:新版本Java对sychronized进行了很多优化,例如自旋锁等,sychronized与ReentrantLock大致相同。
  • 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程选择放弃等待,改为处理其他事情。ReentrantLock可中断,而sychronized不行。
  • 公平锁:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。sychronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但是也可以是公平的。
  • 锁绑定多个条件:一个ReentrantLock可以同时绑定多个Condition对象。

互斥锁选择

使用synchronized是首选方法,除非在需要ReentrantLock的高级功能。

synhronized是由JVM直接支持并实现的锁机制,而不是所有的JDK版本都支持ReentrantLock。此外,使用synchronized没必要担心锁未被释放而引起的死锁,因为JVM会自动确保锁被正确释放。 ReentrantLock需要手动释放锁,所以需要确保在finally块中释放锁,否则容易造成线程死锁。

线程间协作 

join()

join()方法是Thread类中的一个方法,用于等待一个线程的完成。当一个线程在另一个线程上调用join()方法时,调用线程会被阻塞,直到被调用线程执行完毕。

举例:在thread1和thread2执行完毕之后再执行下面打印的代码

Thread thread1 = new Thread(new MyRunnable());
Thread thread2 = new Thread(new MyRunnable());

thread1.start();
thread2.start();

// 在thread1和thread2执行完毕之后再执行下面的代码
thread1.join();
thread2.join();

System.out.println("线程执行完毕");

举例:等待thread1和thread2执行完毕,最多等待5秒

Thread thread1 = new Thread(new MyRunnable());
Thread thread2 = new Thread(new MyRunnable());

thread1.start();
thread2.start();

// 等待thread1和thread2执行完毕,最多等待5秒
thread1.join(5000);
thread2.join(5000);

System.out.println("线程执行完毕");

举例:虽然b线程先启动,但是因为在b线程中调用了a线程的join()方法,b线程会等待a线程结束才继续执行,因此最后能够保证a线程的输出先于b线程的输出。

public class JoinExample {

    private class A extends Thread {
        @Override
        public void run() {
            System.out.println("A");
        }
    }

    private class B extends Thread {

        private A a;

        B(A a) {
            this.a = a;
        }

        @Override
        public void run() {
            try {
                a.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("B");
        }
    }

    public void test() {
        A a = new A();
        B b = new B(a);
        b.start();
        a.start();
    }

    public static void main(String[] args) {
        JoinExample example = new JoinExample();
        example.test();
    }
}

// 输出结果:A  B

wait()notify()notifyAll()

调用wait()使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其他线程会调用notify()或者notifyAll()来唤醒挂起的线程。

  • wait()notify()notifyAll()都属于Object的一部分,而不属于Thread。
  • 只能在同步方法或者同步控制块中使用,否则会在运行时抛出IllegalMonitorStateExecption。
  • 使用wait()挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其他线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行notify()或者notifyAll()来唤醒挂起的线程,造成死锁。
public class WaitNotifyExample {
    public synchronized void before() {
        System.out.println("before");
        notifyAll();
    }

    public synchronized void after() {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("after");
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newCachedThreadPool();
        WaitNotifyExample example = new WaitNotifyExample();
        executorService.execute(() -> example.after());
        executorService.execute(() -> example.before());
    }
}

// 输出结果:before  after

wait()和sleep()的区别?

  • wait()是Object的方法,而sleep()是Thread的静态方法:
  • wait()会释放锁,sleep()不会。

await()signal()signAll()

 java.util.concurrent类库中提供了Condition类来实现线程之间的协调,可以在Condition上调用await()方法使线程等待,其他线程调用signal()或signalAll()方法唤醒等待的线程。相比于wait()这种等待方式。await()可以指定等待的条件,因此更加灵活。

public class AwaitSignalExample {
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();

    public void before() {
        lock.lock();
        try {
            System.out.println("before");
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public void after() {
        lock.lock();
        try {
            condition.await();
            System.out.println("after");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newCachedThreadPool();
        AwaitSignalExample example = new AwaitSignalExample();
        executorService.execute(() -> example.after());
        executorService.execute(() -> example.before());
    }
}

// 输出结果:before  after

Thread方法概述

方法说明
start()启动线程,并使其处于可执行状态。
run()线程的执行体,定义了线程要执行的操作。
sleep(long millis)让当前线程暂停执行指定的毫秒数。
yield()暂停当前正在执行的线程。让其他线程有机会继续执行。
join()等待其他线程终止。
interrupt()中断线程。
isInterrupted()判断线程是否被中断。
isAlive()判断线程是否还存活。
setName(String name)设置线程的名称。
getName()获取线程的名称。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值