跳至主要內容

多线程&并发

酷风大约 8 分钟

多线程&并发

线程和进程

  • 进程:系统运行的一个程序,程序执行的过程;启动Main函数即是 启动了一个JVM 进程
  • 线程:
    • main函数的执行就是一个线程,成为主线程、
    • 进程不同的是同类的多个线程共享进程的堆和方法区资源
    • Java 线程的本质其实就是操作系统的线程
    • 是内核线程,可以利用多核

程序计数器

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

虚拟机栈和本地方法栈

  1. 保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
  2. 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

多线程

  1. 多核 CPU 意味着多个线程可以同时运行
  2. 多线程并发编程正是开发高并发系统的基础
  3. 可能问题
    1. 线程不安全
      1. 同一份数据的正确性和一致性
      2. 不安全导致:数据混乱、错误或丢失等
    2. 死锁等

生命周期和状态:

  1. NEW: 初始状态
  2. RUNNABLE: 运行状态
  3. BLOCKED:阻塞状态
  4. WAITING:等待状态
  5. TIME_WAITING:超时等待状态
  6. TERMINATED:终止状态

  • 线程上下文切换:线程在执行过程中会有自己的运行条件和状态(也称上下文)
    • 上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。
  1. 主动让出 CPU,比如调用了 sleep(), wait() 等。
  2. 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
  3. 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。

线程死锁

  1. 多个线程同时被阻塞
  2. 由于线程被无限期地阻塞,因此程序不可能正常终止。
  • 符合产生死锁的四个必要条件,破坏!!!
  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
    1. 破坏:一次性申请所有的资源
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
    1. 破坏:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
    1. 破坏:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
  • 避免死锁
  1. 靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

线程方法

  • sleep() 方法和 wait() 方法 : 两者都可以暂停线程的执行。
  1. sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
  2. wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
  3. wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
    1. 每个对象(Object)都拥有对象锁,
    2. wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁
    3. 既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。
    4. sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。
  • run 方法
  1. 调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。
  2. start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。
  3. 直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行

JMM(Java 内存模型)

  • 主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。

volatile

  • volatile 关键字可以保证变量的可见性
  • 共享且不稳定的,每次使用它都到主存中进行读取。
  • 还有一个重要的作用就是防止 JVM 的指令重排序
    • 单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!
volatile 关键字修饰也是很有必要
new Singleton()
    1、为 uniqueInstance 分配内存空间
    2、初始化 uniqueInstance
    3、将 uniqueInstance 指向分配的内存地址
    由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2
    多线程环境下会导致一个线程获得还没有初始化的实例
    例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

public class Singleton {

    private volatile static Singleton uniqueInstance;

    private Singleton() {
    }

    public  static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

乐观锁和悲观锁

  • 悲观锁通常多用于写比较多的情况

  • 乐观锁通常多用于写比较少的情况

    • 避免频繁加锁影响性能
  • 悲观锁

  1. 总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改)
  2. 所以每次在获取资源操作的时候都会上锁
  3. Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现
  • 乐观锁
  1. 总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了
  2. 具体方法可以使用版本号机制或 CAS 算法
    1. AtomicInteger:乐观锁的一种实现方式 CAS
    2. CAS: Compare And Swap(比较与交换)
      1. 就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
      2. Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。
      3. 问题:
        1. ABA 问题
        2. 循环时间长开销大
        3. 只能保证一个共享变量的原子操作

synchronized

  • 属于 重量级锁,效率低下。

synchronized vs volatile

  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。
  • 但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

ThreadLocal

  • ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
  • 如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本

线程池

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
  • ThreadPoolExecutor
  • 设定线程池的大小:N CPU核心数
    • CPU 密集型任务(N+1)
      • 利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。
    • I/O 密集型任务(2N)
      • 涉及到网络读取,文件读取这类都是 IO 密集型
      • 计算耗费时间相比于等待 IO 操作完成的时间来说很少

参考网站

上次编辑于:
贡献者: hihcoder