Java并发(二)Java并发理论基础

2024 年 5 月 27 日 星期一(已编辑)
/
60
1
这篇文章上次修改于 2024 年 7 月 24 日 星期三,可能部分内容已经不适用,如有疑问可询问作者。

Java并发(二)Java并发理论基础

为什么需要多线程

  1. 更多的处理核心:线程是一个处理器的最小调度单位,而一个线程同一时间只能运行在一个处理器核心上,单线程无法利用多个处理核心的优势。
  2. 异步执行更快:对一致性要求不高的操作,方便业务拆分,把不同业务单独放一个线程,异步执行,速度更快。

并发编程有什么缺点

并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度

但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、线程安全、死锁等问题。

什么是线程上下文切换

CPU通过时间片分配算法来循环执行线程任务,当前任务执行一个时间片后会切换到下一个线程任务。但是在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换

阻塞会发生上下文切换,减少上下文切换的手段包括:

  • 无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些功法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据
  • CAS算法:Java的Atomic包使用CAS算法来更新数据,而不需要加锁
  • 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态

上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

并行和并发有什么区别?

  • 并发:多个任务在同一个CPU核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行;
  • 并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的”同时进行“;
  • 串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。

Java多线程并发不安全是指什么

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

500个线程同时执行自增操作,操作结束之后它的值有可能小于500。

Java多线程并发出现问题的根源

  1. 可见性:“线程本地内存”引起

    • 具有可见性:一个线程对共享变量的修改,另一个线程能够立刻看到
    • 例:如果一个线程修改值后,存入线程本地内存,还没有存入主内存,另一个线程对修改不可见,会出错
  2. 原子性:分时复用引起(线程切换)

    • 原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
    • 例:i += 1 需要三条指令
      • 变量i从内存读到CPU寄存器
      • 在寄存器执行i+1
      • 结果i写入内存
      • 由于CPU分时复用(线程切换),线程1执行第一条指令后,切到线程2执行三条指令,再回线程1执行,最后执行完两个线程,线程2的结果被覆盖
  3. 有序性:指令重排序引起(编译优化)

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

    • 在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

      • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
      • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令 重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
      • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
      • 从Java源代码到最终实际执行的指令序列,会分别经历上面三种重排序:上述的1属于编译器重排序,2和了3属 于处理器重排序。
    • 重排序导致的问题:多线程出现内存可见性问题,需要利用Java提供的可见性处理工具(volatile,锁等)加以控制

      • // Thread1
        x = 1;
        y = 2;
        
        // Thread2
        if (y == 2) {
            // 由于重排序,这里可能会看到 x = 0
            System.out.println(x);
        }
    • 为什么不禁止重排序:重排序是用来提升性能的,Java内存模型实现时需要取舍

Java是怎么解决并发问题的:JMM(Java内存模型)

JMM:Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:

  • volatile、synchronized和final三个关键字
  • Happens-Before规则

如果正确利用这里的这些JMM方法,可以有效解决可见性,原子性,有序性的问题。

可见性:

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

原子性:

  • Java保证对基本数据类型的变量的读取和赋值操作是原子性操作
  • 如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只用一个线程执行该代码块,自然不存在原子性问题,从而保证了原子性。

有序性:

  • volatile关键字可以保证一定的有序性(通过插入特定的 内存屏障的方式来禁止指令重排序,但仅限于单个变量的读写操作,保证单个变量的读写操作有序性,但无法保证复合操作i++的原子性)
  • 可以通过synchronized和Lock保证有序性,每个时刻只有一个线程执行同步代码,相当于让线程顺序执行同步代码
  • JMM通过Happens-Before规则(JMM承诺程序员基于这套规则编程,即便不理解重排序,程序也不会因为发生了重排序出问题)来保证有序性

JMM

线程之间的通信与同步

并发编程主要就是在处理两个问题:线程之间的通信和线程之间的同步。

通信:是指线程之间应该如何交换信息,主要有两种机制:共享内存和消息传递。

  • 共享内存通信指线程A和B有共享的公共数据区,线程A写数据,线程B读数据,这样就完成了一次隐式通信
  • 而消息传递通信是指线程之间没有公共数据,需要线程间显式的直接发送消息来进行通信(SynchronousQueue、Exchager)
  • Java主要采用的是共享内存的方式,所以线程之间的通信对于开发人员来说都是隐式的

同步:是指一种用来控制不同的线程之间操作发生相对顺序的机制。需要程序员显式的定义,主要是指定一个方法或者一段代码需要在线程之间互斥执行(Java提供了很多用来做同步的工具,比如Synchronized,Lock等)

Java内存模型的抽象结构

在Java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享,共享变量才会有内存可见性问题,所以会受到内存模型的影响。而局部变量,方法定义参数和异常处理器参数不会在线程之间共享,不会有类似问题。

Java线程通信由JMM控制,JMM定义了线程与主内存之间的抽象关系:

  • 线程之间的共享变量存储在主内存中

  • 每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本
  • 本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了高速缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:

synchronized

synchronized关键字的使用

  • 修饰实例方法:锁是当前实例对象
  • 修饰静态方法:也就是给当前类加锁,锁是当前类的Class对象
    • 所以如果一个线程A调用一个实例对象的非静态synchronized方法,而线程B需要调用这个实例对象所属类的静态synchronized方法,是允许的,不会发生互斥现象,因为访问静态synchronized方法占用的锁是当前类的锁,而访问非静态synchronized方法占用的锁是当前实例对象锁
  • 修饰代码块:锁是Synchronized括号里面指定的对象(指定的对象也可以用this,即指定当前实例对象)
synchronized void method() {
    //当前实例对象
}
synchronized static void method() {
    //当前类的Class对象
}
synchronized(this) {
    //Synchronized括号里面指定的对象
}

synchronized底层实现原理

synchronized的语义底层是通过一个monitor(监视器锁)的对象来完成,每个对象有一个监视器锁(monitor)。

每个synchronized修饰过的代码当它的monitor被占用时就会处于锁定状态,当同步代码执行完毕,释放monitor后,其他线程重新尝试获取monitor的所有权,过程:

  1. 如果monitor的进入数为0,则该线程进入monitor,,然后将进入数设置为1,该线程即为monitor的所有者
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权

synchronized可重入的原理

重入锁:是指一个线程获取到该锁之后,该线程可以继续获得该锁。

底层原理维护一个monitor计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

多线程中synchronized锁升级的原理是什么?

synchronized锁升级原理:在锁对象的对象头里面有一个threadid字段,

  1. 在第一次访问的时候threadid为空,JVM让其持有偏向锁,并将threadid设置为其线程id

  2. 再次进入的时候会先判断threadid是否与其线程id一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁

  3. 执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了synchronized锁的升级

  • 无锁状态:对象刚创建时,没有任何线程来竞争锁,此时是无锁状态
  • 偏向锁:当第一个线程尝试获取锁时,JVM会将锁升级为偏向锁,并将该线程的ID记录到对象的Mark Word中。这样,只要该线程再次尝试获取锁,JVM就会检查Mark Word中的线程ID是否与当前线程ID相同,如果相同则直接获得锁,无需进行任何同步操作。这种策略对于只有一个线程频繁访问的场景非常高效。
  • 轻量级锁:如果有多个线程交替地访问同一个对象,偏向锁就会失效。此比时,JVM会尝试将锁升级为轻量级锁。轻量级锁使用了一种称为“自旋等待”的策略,即当线程尝试获取锁但失败时,它会进入一个循环,不断地尝试,再次获取锁,而不是立即阻塞。这种策略对于短时间的锁竞争非常有效,可以避免线程频繁地阻塞和唤醒。
  • 重量级锁:如果自旋等待特续较长时间仍未能获取锁,JVM就会将锁升级为重量级锁。这时,线程的竞争会变得非常激烈,JVM会使用操作系统的互斥量(如互斥锁或条件变量)来实现同步。线程的阻塞和唤醒会涉及到操作系统的内核态和用户态切换,因此性能开销较大。

volatile

volatile关键字的作用

对于可见性,Java提供了volatile关键字来保证可见性和禁止指令重排。

volatile确保一个线程的修改能对其他线程是可见的。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主内存中,当有其他线程需要读取时,它会去主内存中读取新值。

从实践角度而言,volatile的一个重要作用就是和CAS结合,保证了原子性,详细的可以参见java.util.concurrent.atomic包下的类,比如AtomicInteger,volatile常用于多线程环境下的单次操作(单次读或者单次写)。

volatile能使得一个非原子操作变成原子操作吗?

关键字volatile的主要作用是使变量在多个线程间可见,但无法保证原子性,对于多个线程访问同一个实例变量需要加锁进行同步。

虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性(Java内存模型规定的,由于long和double占用64位,一些处理器平台不支持64位原子操作,所以底层由JVM去保证volatile long和volatile double的读写原子性)

volatile变量和atomic原子类变量有什么不同?

volatile变量可以确保先行关系,即写操作会发生在后续的读操作之前,但它并不能保证原子性。例如用volatile修饰count变量,但count++操作不是原子性的。

线程 1 读取 count 的值为 0
线程 2 读取 count 的值为 0
线程 1 将 0 加 1 得到 1,并将结果 1 写回 count
线程 2 将 0 加 1 得到 1,并将结果 1 写回 count
最终 count 的值为 1,而不是期望的 2。

而AtomicInteger类提供的atomic方法可以让这种操作具有原子性,如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。

Java中能创建volatile数组吗?

能,Java中可以创建volatile类型数组,不过只是一个指向数组的引用,而不是整个数组。

意思是,如果改变引用指向的数组,将会受到volatile的保护,但是如果多个线程同时改变数组的元素,volatile标示符就不能起到之前的保护作用了。

final

final关键字有哪些用法?

  • 修饰类:当某个类的整体定义为final时,就表明了你不能打算继承该类,而且也不允许别人这么做。即这个类是不能有子类的

  • 修饰方法:父类的final方法是不能够被子类重写

  • 修饰参数:Java允许在参数列表中以声明的方式将参数指明为final,这意味这你无法在方法中更改参数引用所指向的对象。不希望在方法内改变参数时使用

    • Java public static void printMessage(final String message)
  • 修饰变量:变量的值无法更改。

所有的final修饰的字段都是编译期常量吗?

//编泽期常量
final int a = 1;
final static int b = 1;
fina1int[]c = {1,2,3,4};
//非编泽期常量
Random d = new Random();
final int e = d.nextInt();

e的值由随机数对象决定,所以不是所有的final修饰的字段都是编译期常量,只是e的值在被初始化后无法被更改。

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...