多线程与高并发

22 阅读15分钟

1. 线程基础知识

1.1 基本概念

1.1.1 多进程批量处理

多个任务批量执行

1.1.2 多进行并行处理

把程序卸载不同的内存位置上来回切换

1.1.3 多线程

一个程序内部不同任务来回切换

1.1.4 纤程/协程

绿色线程,用户管理的线程

1.1.5 什么是进程,线程,纤程/协程

程序: 一个操作系统可以执行的文件

程序运行过程: 操作系统找到可执行文件之后加载到内存中 (进程), 然后进行资源分配。之后找到主线程之后交给CPU开始执行。

进程: 资源分配的基本单位,静态的

线程: 程序调度执行的基本单位,动态的。多个线程共享进程中的资源,通俗来讲,线程就是程序中不同的执行路径

  1. 单核CPU设定多线程是否有意义?

有意义的。有的线程是IO线程,在等待网络或者IO输入,这时候是不需要消耗CPU资源的,那么就可以切换另外的线程

  1. 线程数是不是越大越好?

不是。因为线程切换也是需要消耗CPU资源的。

  1. 线程数设置多大最合适

线程数设置公式:

Nthreds =NCPU * UCPU * (1 + W/C )

Ncpu: 处理器的核数,可以通过 Runtime.getRuntime().availableProcessors() 获取

UCPU: 期望的CPU利用率

W/C: 等待时间与计算时间的比率

W/C可以通过Profiler工具统计

2.创建线程的5中方法

1.继承Thread类,重写run()
/**
 * 创建线程方式一
 */
class MyThread extends Thread {    
      @Override    
   public void run() {        
     System.out.println("方式一创建的线程!");    
   }
}

public static void main(String[] args) {
      MyThread myThread = new MyThread();
      // 启动线程1
      myThread.start();
 }
2.实现Runable接口,重写run()
class MyThread2 implements Runnable  {    
  @Override    
  public void run() {        
    System.out.println("方式二创建的线程!");    
  }
}

public static void main(String[] args) {
    Thread myThread2 = new Thread(new MyThread2());
    myThread2.start();
}
3.lamd表达式
public static void main(String[] args) {
    Thread myThread3 = new Thread(() -> {
        @Override    
        public void run() {        
            System.out.println("方式二创建的线程!");    
        }
    });
    myThread3.start();
}
4.使用线程池
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> 
  System.out.println("方式二创建的线程!");
});
executorService.shutdown();
5.实现Callable接口, 可以通过泛型指定线程返回类型
class MyThread5 implements Callable<Integer> {

    @Override
    public Integer call() {
        System.out.println("方式五创建的线程!");
        return 0;
    }
}

public static void main(String[] args) throws ExecutionException, InterruptedException {
    // 实现Callable接口的线程使用方式一
    ExecutorService executorService = Executors.newCachedThreadPool();
    // 通过鲜线程池去执行带返回值的线程
    Future<Integer> submit = executorService.submit(new MyThread5());
    // 通过get()获取线程返回值  该方法时阻塞的
    Integer num = submit.get();
    
    // 实现Callable接口的线程使用方式二
    FutureTask<Integer> futureTask = new FutureTask<>(new MyThread5());
    Thread t = new Thread(futureTask);
    // 启动线程
    t.start();
    // 获取线程返回值
    futureTask.get();
}

3.线程状态

4.线程设置标志位(interrupt)

1.interrupt()

设置标志位, 只是说给调用该方法的线程设置一个标记

2.isInterrupted()

查询某个线程是否设置标志位

3.static interrupted()

查询当前线程是否设置标志位,并重置标志位

/**
 * interrupt 相关方法联系
 * @auth 九幽
 * @date 2023/5/23 14:23
 */
public class T2_Interrupt {

    public static void main(String[] args) {
        m1();
        m2();
    }

    private static void m1() {
        // 创建一个线程
        Thread t = new Thread(() -> {
            for (; ; ) {
                // 判断线程是否设置了标志位
                if (Thread.currentThread().isInterrupted()) {
                    // 可以优雅的退出线程
                    System.out.println("退出线程");
                    break;
                }
            }
        });
        // 启动线程
        t.start();
        // 设置线程标志位
        t.interrupt();
    }

    private static void m2() {
        // 创建一个线程
        Thread t = new Thread(() -> {
            for (; ; ) {
                // 判断线程是否设置了标志位
                if (Thread.interrupted()) {
                    // 调用interrupted()之后,当前线程标志位会被重置,也就是说  
                    // Thread.currentThread().isInterrupted() ==> false
                    System.out.println(Thread.currentThread().isInterrupted());
                }
            }
        });
        // 启动线程
        t.start();
        // 设置线程标志位
        t.interrupt();
    }
}

当线程调用sleep(), wait(), join()方法的时候, 如果对线程设置标志位,会抛出 InterruptedException 异常。同时会重置线程的标志位

/**
 * interrupt 相关方法联系
 * @auth 九幽
 * @date 2023/5/23 14:23
 */
public class T2_Interrupt {

    public static void main(String[] args) {
        m1();
    }

    private static void m1() {
        // 创建一个线程
        Thread t = new Thread(() -> {
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) {
                // 捕获异常之后可进行相应操作

            }
        });
        // 启动线程
        t.start();

        try {
            t.wait();
            t.join();
        } catch (InterruptedException e) {
            // 捕获异常之后可进行相应操作
        }
        // 设置线程标志位
        t.interrupt();
    }
}

设置标志位不会影响正在争抢锁的线程。如果需要影响到强锁的线程,需要使用 ReentranLock中的lockInterruptibly()

/**
 * interrupt 相关方法联系
 * @auth 九幽
 * @date 2023/5/23 14:23
 */
public class T2_Interrupt {

    private static ReentrantLock lock = new ReentrantLock();
    
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            lock.lock();
            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            lock.unlock();
        });

        Thread t2 = new Thread(() -> {
            try {
                lock.lockInterruptibly();
            } catch (InterruptedException e) {
                // 捕获异常之后可进行相应操作
            } finally {
                lock.unlock();
            }

        });
         t1.start();
         t2.start();
         t2.interrupt();
    }
}

5.线程的结束

1.stop() => 已被弃用

使用stop()可以立即停止调用stop()的线程。但是一般不建议使用。因为stop()太粗暴了,不论线程处于什么状态,都会立即停止,然后释放所有的锁,并且不会做任何善后的操作,非常容易产生数据不一致的问题

2.suspend() 线程暂停 => 已被弃用

   resume()  线程继续  => 已被弃用

suspend()被调用时不会释放锁,会产生死锁的问题

3.volatile方式,也就是设置flag

volatile是Java中的关键字,用来修饰会被不同线程访问和修改的变量。JMM(Java内存模型)是围绕并发过程中如何处理可见性、原子性和有序性这3个特征建立起来的,而volatile可以保证可见性,有序性

/**
 * 优雅的结束线程的两种方式
 * @auth 九幽
 * @date 2023/5/23 15:22
 */
public class T3_ThreadStop {

    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            long i = 0L;
            while (running) {
                i++;
            }
            System.out.println(i);
            // 第一次 2016815324 第二次 2010010363
            // 说明使用volatile不能控制线程停止的准确时间
        });

        t.start();
        Thread.sleep(500);
        running = false;
    }

}
4.interrupt结束线程
/**
 * 优雅的结束线程的两种方式
 * @auth 九幽
 * @date 2023/5/23 15:22
 */
public class T3_ThreadStop {

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            long i = 0L;
            while (!Thread.interrupted()) {
                i++;
            }
            System.out.println(i);
            // 第一次 833636686 第二次 799334381
        });

        t.start();
        Thread.sleep(500);
        t.interrupt();
    }

}

并发编程三大特性

  • 可见性(visibility)
  • 有序性(ordering)
  • 原子性(atomicity)

1.可见性(visibility)

/**
 * 并发编程中可见性的问题
 * @auth 九幽
 * @date 2023/5/23 15:43
 */
public class T4_Visibility {

    private static /*volatile*/ boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        new Thread(T4_Visibility::m).start();

        Thread.sleep(100);

        running = false;
    }

    private static void m() {
        System.out.println("线程开始!");
        while (running) {
//            System.out.println("线程运行中!");
        }
        System.out.println("线程结束!");
    }

}

分析: 上面程序运行之后,虽然在主线程中更新了running,但是线程并不会结束,这就是可见性的问题。

running存在主内存中,每个线程在读取running时都会先保存一份到线程缓存中,之后都是从线程缓存中读取数据,而不会从主内存中读取。这就导致某一个线程修改了running之后对于其他线程来说是不可见的。

解决可见性问题: 使用volatile来修饰running

线程每次读取volatile修饰的变量时都会从主内存中读取最新的值,而别的线程修改之后都会更新到主内存中。

注意:volatile修饰的引用类型只能保证引用本身的可见性,不能保证内部数据的可见性。例子如下:

/**
 * 并发编程中可见性的问题
 * @auth 九幽
 * @date 2023/5/23 15:43
 */
public class T4_Visibility {

    static class A {
        boolean running = true;
        void m() {
            System.out.println("线程开始!");
            while (running) {}
            System.out.println("线程结束!");
        }
    }
        
    // a 用volatile修饰。但是a里面的running属性的修改对于其他线程来说仍然是不可见的
    private volatile static A a = new A();

    public static void main(String[] args) throws InterruptedException {
        new Thread(a::m).start();
        Thread.sleep(100);
        a.running = false;
    }

}
三级缓存

硬件层面 采用 MESI 协议解决CPU数据一致性问题

一级缓存和二级缓存位于CPU核内,三级缓存位于CPU中,由同一个CPU中的多个核共用

CPU读取数据时会沿着一级缓存,二级缓存,三级缓存一层一层找,如果没有则到主内存中读取,之后再三个缓存中进行存储

缓存行(64字节): CPU再=在读取数据时是按照一块一块去读取的,也就是说会把读取的数据以及其周边的数据一整块读取到CPU中。这一整块数据被称为缓存行,总共有64字节的长度

缓存一致性:

缓存行对齐: @Contended -XX:-RestrictContended 只有1.8有效

2.有序性(ordering)

1.乱序的验证
/**
 * 验证程序的无序性
 * @auth 九幽
 * @date 2023/5/23 19:27
 */
public class T5_Ordering {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;
    public static void main(String[] args) throws InterruptedException {
        for (long i = 0; i < Long.MAX_VALUE; i++) {

            x = 0;
            y = 0;
            a = 0;
            b = 0;
            // 开启两个线程的计数器
            CountDownLatch latch = new CountDownLatch(2);
            Thread one = new Thread(() -> {
                a = 1;
                x = b;
                latch.countDown();
            });
            Thread two = new Thread(() -> {
                b = 1;
                y = a;
                latch.countDown();
            });
            one.start();
            two.start();
            latch.await();
            String result = "第" + i + "次,(" + x + "," + y + ")";

            if (x == 0 && y == 0) {
                System.out.println(result);
                break;
            }
        }
    }
}

执行结果:第10916次,(0,0)

结果分析

只有当两个线程中的两条执行语句调换顺序时才会出现 x = 0,y = 0的情况。也就是说在某些情况下,程序的执行顺序会发生变化。

乱序现象:为了提高效率

乱序存在的条件:as-if-searial

两条语句不存在依赖关系,不影响单线程的最终一致性

2.阻止指令重排序

1.在CPU层面中使用内存屏障阻止乱序执行

2.JVM内存屏障  Load => 读  Store => 写

JVM要求所有虚拟机必须实现以下四种屏障 

LoadLoad屏障: 对于这样的语句Load1;LoadLoad;Load2,

在Load2及后续读取操作要读取的数据被访问前,保证Loadi要读取的数据被读取完毕

StoreStore屏障: 对于这样的语句Store1; StoreStore; Store2,

在Store2及后续写入操作执行前,保证store1的写入操作对其它处理器可见

LoadStore屏障: 对于这样的语句Load1; LoadStore;Store2,

在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

StoreLoad屏障: 对于这样的语句Store1; StoreLoad; Load2,

在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

volatile禁止指令重排序的实现细节

volatile在hotspot中的实现

底层使用Lock; addl指令实现

3.原子性(atomicity)

race condition => 竞争条件,指多个线程访问共享数据的时候产生竞争

数据不一致(unconsistency),指并发访问之下产生的不期望出现的结果

/**
 * @auth 九幽
 * @date 2023/5/24 13:18
 */
public class T1_Atomicity {

    private static long n = 0L;

    public static void main(String[] args) throws InterruptedException {

        Thread[] threads = new Thread[100];

        CountDownLatch latch = new CountDownLatch(100);

        for (Thread thread : threads) {
            thread = new Thread(() -> {
                for (int i = 0; i < 10000; i++) {
                    n++;
                }
                latch.countDown();
            });
            thread.start();
        }

        latch.await();

        System.out.println(n);

    }

}
// 在这个程序中,理想状态下运行结果应该是1000000,但是运行结果是不一样的。
// 因为在这个程序中多个线程共同访问 n 
// 可以通过上锁的方式来保障语句的原子性
1.上锁的本质:将原来的并发操作变成序列化操作
2.锁的相关概念

monitor(管程) => 上的锁

critical section(临界区) => 被锁包围的代码片段

如果临界区执行时间长,语句多,叫做锁的粒度粗,反之就是锁的粒度细

3.如何保证数据一致性 => 线程同步(线程的执行顺序安排好),具体来讲就是保证操作的原子性
  1. 悲观的认为这个操作会被别的线程打断 (悲观锁) synchronized
  2. 乐观的认为这个操作不会被别的线程打断 (乐观锁) cas操作

cas = Compare And Set/Swap/Exchange

4.悲观锁 使用 synchronized
        for (Thread thread : threads) {
            thread = new Thread(() -> {
                for (int i = 0; i < 10000; i++) {
                    synchronized (T1_Atomicity.class) {
                        n++;
                    }
                }
                latch.countDown();
            });
            thread.start();
        }
5.乐观锁/自旋锁 cas操作
    static AtomicLong a_long = new AtomicLong(0);

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[100];

        CountDownLatch latch = new CountDownLatch(100);

        for (Thread thread : threads) {
            thread = new Thread(() -> {
                for (int i = 0; i < 10000; i++) {
                    a_long.incrementAndGet();
                }
                latch.countDown();
            });
            thread.start();
        }

        latch.await();

        System.out.println(a_long.get());
    }

ABA问题:

CAS操作本身保障原子性:CPU底层:lock cmpxchg 指令

6.两种锁的使用

临界区执行时间比较长,等待线程多使用悲观锁反之使用乐观锁

关于Object o = new Object()面试题

1.对象的创建过程

底层字节码指令

0 new #2 <java/lang/Object>    ---> 创建一个指定类型的对象,在堆上分配内存,并给该实例的实例字段进行初始化                                                                        成默认值,再将对象引用的指针压入栈顶。

3 dup      ---> 从栈顶复制一个值,将复制的值重新压入栈顶

4 invokespecial #1 <java/lang/Object. : ()V>    -----> 调用对象的构造方法并进行初始化

7 astore_1     -----> 建立连接

8 return

2.DCL与Volatile问题

DCL --> Double Check Lock  双重检查锁

volatile 有两个作用:1. 保持线程可见  2. 禁止指令重排

由于Java对象的创建过程在底层由5条指令构成,概率上存在指令重排序的情况。这就导致在多线程的情况下可能获取到的对象是一个半初始化的状态。这就需要使用volatile进行修饰。volatile修饰的内存在前后加了屏障,可以禁止指令重排序。

3.对象在内存中的布局

markword: 标记字

class pointer: 类型指针  保存指向对象的类class指针   -XX:

instance data: 实例数据  保存对象中是属性数据

padding: 对其(8字节对齐)

length: 数组对象中保存数组长度

指针压缩:在32G内存及以上的系统中指针压缩无效

java -XX:+PrintCommandLineFlags -version  可以查看jvm默认的设置

-XX:+UseCompressedClassPointers  启动类型指针压缩   压缩之后占用4字节,不压缩占用8字节

-XX:+UseCompressedOops  启动普通对象指针压缩  压缩之后占用4字节,不压缩占用8字节

Oops ordinary object pointers  普通对象指针

可以通过jol-core工具查看

image.png

    Object  = new Object();
    String s = ClassLayout.parseInstance(o).toPringtable();
    System.out.println(s);
4.对象头具体包含什么

1.markword   标记字

1.锁信息 synchronized

2.hashcode信息 独一无二的hashcode 

3.GC信息

2.class pointer  类型指针

5.对象怎么定位

直接定位:通过指针找到对象   hotspot 使用直接定位

间接定位:句柄池方式,包含两个指针1. 实例数据指针 2. 类型数据指针

6.对象怎么分配

一个对象刚new出来的时候首先尝试能否在栈帧中分配,如果可以就分配在栈内存中,不能则判断该对象是否足够大,如果足够大则放入老年代由FGC回收。如果对象不大则尝试分配TLAB (Thread Local Allocation Buffer 线程本地分配缓冲区),如果分配不下则分配新生区。新生代中的对象经过一次垃圾回收,回收掉则结束,如果没有回收掉则进入s1,年龄够进入老年代,否则进入s2

注意: 可以在栈空间中分配的对象需要满足两个要求 1. 逃逸分析 2. 标量替换

7.对象创建在内存中占多少字节

Object o = new Object()

1.对象引用 o 占用4字节

2.markword 占用8字节

3.class pointer 开启压缩占用4字节

4.对象空间补齐4字节

8.为什么Hotspot不使用C++对象来代替Java对象

C++对象中有一个指针指向虚方法表

Java中使用oop-klass二元组和

9.Class对象是在堆还是方法区

方法区

Synchronized 锁升级深入详解

CAS

1.用户态与内核态

JDK早期,synchronized 叫做重量级锁,因为申请所资源必须通过kernel 系统调用

2.markword

3.锁升级

偏向锁,轻量级锁是用户空间的

1.偏向锁:多数时间中 synchronized 代码片段只在一个线程中使用

将当前线程的ID/指针放入markword中

2.轻量级锁/自旋锁:两个线程共同竞争一把锁

1.撤销偏向锁

2.两个线程使用自旋的方式去竞争锁。竞争过程:每个线程在各自的线程栈空间内生成LR **(Lock Record 锁记录), **将竞争到锁的LR指针的放入markword中,另外的线程继续使用CAS方式竞争锁

3.重量级锁:内核空间的,需要OS调度

4.锁升级的细节

1.锁重入

synchronized是可重入锁。重入次数必须记录。因为需要和解锁次数相对应

偏向锁/自旋锁 重入次数记录在线程栈中,LR+1。

重量级锁 重入次数记录在 ObjectMonitor 字段上

2.轻量级锁升级为重量级锁

如果竞争加剧 **(有线程自旋超过10次) **或者自旋线程数超过CPU核数的一半,则升级重量级锁。

注:在1.6之后升级重量级锁的条件由JVM自动控制

  1. 为什么有自旋锁还需要重量级锁?

自旋是需要消耗CPU资源的,如果锁的时间长,或者自旋线程多,CPU会被大量消耗

而重量级锁是有队列的。其他线程进入等待队列中,是不需要消耗CPU资源的

  1. 偏向锁是否一定比自旋锁效率高?

不一定。在明知道会有多线程竞争的情况下,偏向锁肯定会涉及到锁撤销,而锁撤销是需要消耗CPU资源的,这时候直接使用自旋锁。

JVM启动过程中,会有很多线程竞争,所以默认情况下启动时不打开偏向锁,过一段时间之后再打开 默认4s