并发编程知识总结-线程相关!

1,475 阅读10分钟

欢迎关注公众号:月伴飞鱼,每天分享程序员职场经验!

文章内容收录到个人网站,方便阅读:hardyfish.top/

资料分享

JAVA并发编程实践(中文):

Java并发编程之美:

Java多线程编程核心技术:

线程优先级

线程的优先级:线程抢占 CPU 时间片的概率,优先级越高的线程优先执行的概率就越大,但并不能保证优先级高的线程一定先执行。

// 线程可以拥有的最小优先级
public final static int MIN_PRIORITY = 1;

// 线程默认优先级
public final static int NORM_PRIORITY = 5;

// 线程可以拥有的最大优先级
public final static int MAX_PRIORITY = 10

线程的常用方法

join():

在一个线程中调用 other.join() ,这时候当前线程会让出执行权给 other 线程,直到 other 线程执行完或者过了超时时间之后再继续执行当前线程。

yield():

给线程调度器一个当前线程愿意出让 CPU 使用权的暗示,但是线程调度器可能会忽略这个暗示。

  • yield() 执行非常不稳定,线程调度器不一定会采纳 yield() 出让 CPU 使用权的建议。

wait/notify和sleep方法的异同?

wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求。

在同步代码中执行 sleep 方法时,并不会释放 monitor 锁,但执行 wait 方法时会主动释放 monitor 锁。

sleep 方法中会要求必须定义一个时间,时间到期后会主动恢复,而对于没有参数的 wait 方法而言,意味着永久等待,直到被中断或被唤醒才能恢复,它并不会主动恢复。

wait/notify 是 Object 类的方法,而 sleep 是 Thread 类的方法。

线程状态

新建状态(New):

通过实现Runnable接口或继承Thread声明一个线程类,new一个实例后,线程就进入了新建状态。

就绪状态(Ready):

线程创建成功后,调用该线程的start()函数,线程进入就绪状态,该状态的线程进入可运行线程池中,等待获取CPU的使用权。

运行状态(Running):

可运行线程池中选择一个线程,该线程进入运行状态。

  • 线程获取到了CPU时间片。
  • 当线程时间片用完或调用的yield()函数,该线程回到就绪状态。

终止状态(Terminated):

线程执行结束或执行过程中因异常意外终止都会使线程进入终止状态。

等待状态(Waiting):

运行状态的线程执行wait()、join()、LockSupport.park()任意函数,该线程进入等待状态。

  • 其中wait()join()函数会让JVM把该线程放入锁等待队列。

  • 处于这种状态的线程不会被分配CPU执行时间,它们要等待被主动唤醒,否则会一直处于等待状态。

  • 执行LockSupport.unpark(t)函数唤醒指定线程,该线程回到就绪状态。

  • 通过notify()、notifyAll()、join线程执行完毕方式,会唤醒锁等待队列的线程,出队的线程回到就绪状态。

超时等待状态(Timed waiting):

超时等待与等待状态一样,唯一的区别就是多了超时机制,不会一直等待被其他线程主动唤醒,而是到达指定时间后会自动唤醒

阻塞状态(Blocked):

运行状态的线程获取同步锁失败或发出I/O请求,该线程进入阻塞状态。

  • 如果是获取同步锁失败JVM还会把该线程放入锁的同步队列。
  • 同步锁被释放时,锁的同步队列会出队所有线程,进入就绪状态。
  • I/O处理完毕时,该线程重新回到就绪状态。

线程安全

多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行问题,也不需要进行额外的同步,而调用这个对象的行为都可以获得正确的结果,那这个对象便是线程安全的。

什么场景需要注意线程安全问题?

访问共享变量或资源:

有访问共享对象的属性,访问 static 静态变量,访问共享的缓存等等。

因为这些信息不仅会被一个线程访问到,还有可能被多个线程同时访问,那么就有可能在并发读写的情况下发生线程安全问题。

依赖时序的操作:

如果我们操作的正确性是依赖时序的,而在多线程的情况下又不能保障执行的顺序和我们预想的一致,这个时候就会发生线程安全问题。

if (map.containsKey(key)) {
    map.remove(obj)
}

互斥与同步的区别

互斥:

  • 某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。

同步:

  • 实现访问者对资源的有序访问。

停止线程

stop()

直接把线程停止,这样就没有给线程足够的时间来处理想要在停止前保存数据的逻辑,任务戛然而止。

  • 会导致出现数据完整性等问题。

suspend()resume()

如果线程调用 suspend(),它并不会释放锁,就开始进入休眠,但此时有可能仍持有锁,这样就容易导致死锁问题。

  • 因为这把锁在线程被 resume() 之前,是不会被释放的。

interrupt()

一旦调用某个线程的 interrupt() 之后,这个线程的中断标记位就会被设置成 true

每个线程都有这样的标记位,当线程执行时,应该定期检查这个标记位。

  • 如果标记位被设置成 true,就说明有程序想终止该线程。

while 循环体判断语句中,通过 Thread.currentThread().isInterrupt()判断线程是否被中断。

while (!Thread.currentThread().islnterrupted() && more work to do) {
    do more work
}
public class StopThread implements Runnable {
 
    @Override
    public void run() {
        int count = 0;
        while (!Thread.currentThread().isInterrupted() && count < 1000) {
            System.out.println("count = " + count++);
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new StopThread());
        thread.start();
        Thread.sleep(5);
        thread.interrupt();
    }
}

如果处于休眠(sleep、wait)中的线程被中断,那么线程是可以感受到中断信号的。

  • 并且会抛出一个 InterruptedException 异常。

  • 同时清除中断信号,将中断标记位设置成 false

这样一来就不用担心长时间休眠中线程感受不到中断了,因为即便线程还在休眠,仍然能够响应中断通知,并抛出异常。

如果负责编写的方法需要被别人调用,方法内调用了 sleep 或者 wait 等能响应中断的方法时,仅仅 catch 住异常是不够的。

方式一:方法签名抛异常,run() 强制 try/catch

将中断信号层层传递到顶层,最终让 run() 方法可以捕获到异常。

void subTask() throws InterruptedException {
    Thread.sleep(1000);
}

方式二:再次中断:

手动添加中断信号,中断信号依然可以被捕捉到。

这样后续执行的方法依然可以检测到这里发生过中断,可以做出相应的处理。

private void reInterrupt() {
    try {
        Thread.sleep(2000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        e.printStackTrace();
    }
}

生产者消费者模式

public class MyBlockingQueueForCondition {
 
   private Queue queue;
   private int max = 16;
   private ReentrantLock lock = new ReentrantLock();
   private Condition notEmpty = lock.newCondition();
   private Condition notFull = lock.newCondition();
 
 
   public MyBlockingQueueForCondition(int size) {
       this.max = size;
       queue = new LinkedList();
   }
 
   public void put(Object o) throws InterruptedException {
       lock.lock();
       try {
           while (queue.size() == max) {
               notFull.await();
           }
           queue.add(o);
           notEmpty.signalAll();
       } finally {
           lock.unlock();
       }
   }
 
   public Object take() throws InterruptedException {
       lock.lock();
       try {
           while (queue.size() == 0) {
               notEmpty.await();
           }
           Object item = queue.remove();
           notFull.signalAll();
           return item;
       } finally {
           lock.unlock();
       }
   }
}

Volatile

相比于 synchronized 关键字(重量级锁)对性能影响较大。

使用 volatile 不会引起上下文的切换和调度,所以 volatile 对性能的影响较小,开销较低。

volatile 可以保证其修饰的变量的可见性有序性,无法保证原子性(不能保证完全的原子性,只能保证单次读/写操作具有原子性,即无法保证复合操作的原子性)。

volatile 如何实现可见性?

volatile 修饰的共享变量 flag 被一个线程修改后,JMM(Java内存模型)会把该线程的CPU内存中的共享变量 flag 立即强制刷新回主存中,并且让其他线程的CPU内存中的共享变量 flag 缓存失效,这样当其他线程需要访问该共享变量 flag 时,就会从主存获取最新的数据。

image.png

volatile 实现可见性的原理

Lock指令(汇编指令):

volatile 修饰的变量会多一个lock前缀的指令

会将处理器缓存的数据写回主存中,同时使其他线程的处理器缓存的数据失效,这样其他线程需要使用数据时,会从主存中读取最新的数据,从而实现可见性。

内存屏障(CPU指令):

为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。

JMM 提供了内存屏障阻止这种重排序。

Store屏障:

  • 当一个线程修改了volatile变量的值,它会在修改后插入一个写屏障,告诉处理器在写屏障之前将所有存储在缓存中的数据同步到主内存

Load屏障:

  • 当另一个线程读取volatile变量的值,它会在读取前插入一个读屏障,告诉处理器在读屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果

如果被 volatile 修饰时会多一个 ACC_VOLATILE,JVM把字节码生成机器码时会在相应位置插入内存屏障指令,因此可以通过读写屏障实现 volatile 修饰变量的可见性。

volatile 如何实现有序性?

volatile 保证变量有序性,禁止指令重排序。

volatile 实现有序性的原理

Java编译器会在生成指令时在适当位置插入内存屏障来禁止特定类型的处理器重排序。

内存屏障中禁止指令重排序的内存屏障的四种指令:

指令说明
LoadLoad 屏障保证在该屏障之后的读操作,不会被重排序到该屏障之前的读操作
StoreStore屏障保证在该屏障之后的写操作,不会被重排序到该屏障之前的写操作,并且该屏障之前的写操作已被刷入主存
StoreLoad 屏障保证在该屏障之后的读操作,能够看到该屏障之前的写操作对应变量的最新值
LoadStore 屏障保证在该屏障之后的写操作,不会被重排序到该屏障之前的读操作

volatile的插入屏障策略

  • 在每个 volatile 操作的前面插入一个 StoreStore 屏障
  • 在每个 volatile 操作的后面插入一个 StoreLoad 屏障
  • 在每个 volatile 操作的后面插入一个 LoadLoad 屏障
  • 在每个 volatile 操作的后面插入一个 LoadStore 屏障

即在每个volatile写操作前后分别插入内存屏障,在每个volatile读操作后插入两个内存屏障。

image.png

volatile 为什么不能保证原子性?

volatile 无法保证复合操作的原子性,但能保证单个操作的原子性。

volatile 常见的应用场景?

状态标志位:

使用 volatile 修饰一个变量通过赋值不同的常数或值来标识不同的状态

/**
 * 可以通过布尔值来控制线程的启动和停止
 */
public class MyThread extends Thread {
    
    // 状态标志变量
    private volatile boolean flag = true;
    
    // 根据状态标志位来执行
    public void run() {
        while (flag) {
            // do something
        }
    }
    // 根据状态标志位来停止
    public void stopThread() {
        flag = false; // 改变状态标志变量
    }
}

双重检查DLC:

单例模式的双重检查DLC可以通过 volatile 来修饰从存储单例模式对象的变量。