likes
comments
collection
share

Java多线程基本概念, 常用关键字及常用锁类

作者站长头像
站长
· 阅读数 38

常用关键字及常用锁类后续还会补充, 先挖个坑

  1. 线程的状态
  • New:新创建的线程,尚未执行;

  • Runnable:运行中的线程,正在执行run()方法的Java代码;

  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;

  • Waiting:运行中的线程,因为某些操作在等待中;

  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;

  • Terminated:线程已终止,因为run()方法执行完毕。

    放两张图帮助理解:

Java多线程基本概念, 常用关键字及常用锁类

Java多线程基本概念, 常用关键字及常用锁类

注意Blocked和Waiting的区别(TODO: 待补充):

  • Blocked: 等待同步锁(syncronized, ReentrantLock), 处于锁竞争状态, 但未获取到锁
  • Waiting: 等待其他线程执行完成(join(), wait()), 会将持有的锁释放
  1. 守护线程
  • 所有非守护线程都执行完毕后,无论有没有守护线程,JVM都会自动退出。
  • 使用守护线程的一个常见例子是在后台执行周期性的任务,例如GC, 定时任务或日志清理等。
  1. 线程同步
  • 临界区: 加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。

对于语句:

n = n + 1;

看上去是一行语句,实际上对应了3条指令:

ILOAD
IADD
ISTORE

我们假设n的值是100,如果两个线程同时执行n = n + 1,得到的结果很可能不是102,而是101,原因在于:

Java多线程基本概念, 常用关键字及常用锁类

加锁后:

Java多线程基本概念, 常用关键字及常用锁类

  • JVM规范定义的几种原子操作(单行, 多行赋值还是需要同步的):

基本类型(longdouble除外)赋值,例如:int n = m

引用类型赋值,例如:List<String> list = anotherList

longdouble是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把longdouble的赋值作为原子操作实现的。

单条原子操作的语句不需要同步。

  1. 死锁

一个线程可以获取一个可重入锁后,再继续获取另一个可重入锁:

public void add(int m) {
    synchronized(lockA) { // 获得lockA的锁
        this.value += m;
        synchronized(lockB) { // 获得lockB的锁
            this.another += m;
        } // 释放lockB的锁
    } // 释放lockA的锁
}

public void dec(int m) {
    synchronized(lockB) { // 获得lockB的锁
        this.another -= m;
        synchronized(lockA) { // 获得lockA的锁
            this.value -= m;
        } // 释放lockA的锁
    } // 释放lockB的锁
}

在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()dec()方法时:

  • 线程1:进入add(),获得lockA
  • 线程2:进入dec(),获得lockB

随后:

  • 线程1:准备获得lockB,失败,等待中;
  • 线程2:准备获得lockA,失败,等待中。

此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。

死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。

因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。

那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lockA,再获取lockB的顺序,改写dec()方法如下:

public void dec(int m) {
    synchronized(lockA) { // 获得lockA的锁
        this.value -= m;
        synchronized(lockB) { // 获得lockB的锁
            this.another -= m;
        } // 释放lockB的锁
    } // 释放lockA的锁
}
  1. 常用锁类
  • ReentrantLock: 可重入锁, 和synchronized类似
if (lock.tryLock(1, TimeUnit.SECONDS)) {
   try {
       ...
   }
   catch(...) {
   ...
   }
   finally {
       lock.unlock();
   }
}

优点:

  1. 更灵活, lock()unlock()可以跨多个不同的方法, 不同的代码块调用,
  2. tryLock()可以高性能, 超过时间就不再等待
  3. tryLock()可以提高安全性, 避免死锁

缺点:

  1. 需要在finally代码块手动调用unlock()
  2. 需要自己处理异常
  • Condition: 可以从Lock对象的实例获取其Condition(其它比如ReadWriteLock是没有Condition的),Condition提供的await()signal()signalAll()原理和synchronized锁对象的wait()notify()notifyAll()是一致的,并且其行为也是一样的
class TaskQueue {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private Queue<String> queue = new LinkedList<>();

    public void addTask(String s) {
        lock.lock();
        try {
            queue.add(s);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public String getTask() {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                condition.await();
            }
            return queue.remove();
        } finally {
            lock.unlock();
        }
    }
}

tryLock()类似,await()可以在等待指定时间后,如果还没有被其他线程通过signal()signalAll()唤醒,可以自己醒来

if (condition.await(1, TimeUnit.SECOND)) {
    // 被其他线程唤醒
} else {
   // 指定时间内没有被其他线程唤醒
}
  • ReadWriteLock: 悲观读锁,可重入锁. 把读写操作分别用读锁和写锁来加锁, 允许多个线程同时读(当有一个线程持有读锁, 其他线程可以获取读锁, 这样就大大提高了并发读的执行效率), 但它只允许一个线程写入(当有一个线程持有写锁, 其他线程读锁和写锁都获取不到)
public class Counter {
    private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
    private final Lock rlock = rwlock.readLock();
    private final Lock wlock = rwlock.writeLock();
    private int[] counts = new int[10];

    public void inc(int index) {
        wlock.lock(); // 加写锁
        try {
            counts[index] += 1;
        } finally {
            wlock.unlock(); // 释放写锁
        }
    }

    public int[] get() {
        rlock.lock(); // 加读锁
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            rlock.unlock(); // 释放读锁
        }
    }
}
  • StampedLock: 乐观读锁,不可重入锁.StampedLockReadWriteLock相比,不同之处在于: 读的过程中也允许获取写锁后写入,这样一来,我们读的数据就可能不一致,但需要一点额外的代码来判断读的过程中是否有写入
public class Point {
    private final StampedLock stampedLock = new StampedLock();

    private double x;
    private double y;

    public void move(double deltaX, double deltaY) {
        long stamp = stampedLock.writeLock(); // 获取写锁
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            stampedLock.unlockWrite(stamp); // 释放写锁
        }
    }

    public double distanceFromOrigin() {
        long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
        // 注意下面两行代码不是原子操作
        // 假设x,y = (100,200)
        double currentX = x;
        // 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
        double currentY = y;
        // 此处已读取到y,如果没有写入,读取是正确的(100,200)
        // 如果有写入,读取是错误的(100,400)
        if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
            stamp = stampedLock.readLock(); // 获取一个悲观读锁
            try {
                currentX = x;
                currentY = y;
            } finally {
                stampedLock.unlockRead(stamp); // 释放悲观读锁
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

主要关注读的方法distanceFromOrigin(), validate()获取版本号,如果在读取过程中有写入,版本号和乐观读锁tryOptimisticRead()的不同, 则获取悲观读锁, lock()之后, 再重新获取一次最新值

乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。

  1. Thread类常用方法
  • join(): 等待线程执行完成, 可指定等待时间, 超过即不再等待;
  • stop(): 强行终止线程, 不推荐使用, 推荐用interrupt设置中断标记 TODO: 原因暂缓了解
  • interrupt(): 设置中断标记, 对于处在Waiting状态的线程, 会立刻抛出InterruptedException;
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new MyThread();
        t.start();
        Thread.sleep(1000);
        t.interrupt(); // 中断t线程
        t.join(); // 等待t线程结束
        System.out.println("end");
    }
}

class MyThread extends Thread {
    public void run() {
        Thread hello = new HelloThread();
        hello.start(); // 启动hello线程
        try {
            hello.join(); // 等待hello线程结束
        } catch (InterruptedException e) {
            System.out.println("interrupted!");
        }
        hello.interrupt();
    }
}

class HelloThread extends Thread {
    public void run() {
        int n = 0;
        while (!isInterrupted()) {
            n++;
            System.out.println(n + " hello!");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}

main线程通过调用t.interrupt()从而通知t线程中断,而此时t线程正位于hello.join()的等待中,此方法会立刻结束等待并抛出InterruptedException。由于我们在t线程中捕获了InterruptedException,因此,就可以准备结束该线程。t线程结束前,对hello线程也进行了interrupt()调用通知其中断。如果去掉这一行代码,可以发现hello线程仍然会继续运行,且JVM不会退出。

可使用自定义的变量来代替interrupt()以避免抛出异常, 而且更加灵活, 注意要用volatile修饰变量以保证其可见性

  • setDeamon()/isDeamon(): 设定/检查是否为守护线程

  • setPriority(): 设定线程优先级1-10, 默认为5, 优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。

  • wait()notify()/notifyAll():

    • wait()使线程进入等待状态, wait()方法返回时需要重新获得锁

    • 使用notifyAll()将唤醒所有当前正在this锁等待的线程,而notify()只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。通常来说,notifyAll()更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。

    • 调用wait()notify()/notifyAll()的方法必须是synchronized的, 否则会抛出IllegalMonitorStateException

    • wait()notify()/notifyAll()都是object类的方法, 必须在已获得的锁对象上调用它们

    1. synchronized解决了多线程竞争的问题, 但没有解决并没有解决多线程协调的问题
    class TaskQueue {
        Queue<String> queue = new LinkedList<>();
    
        public synchronized void addTask(String s) {
            this.queue.add(s);
        }
    
        public synchronized String getTask() {
            while (queue.isEmpty()) {
            }
            return queue.remove();
        }
    }
    

    while()循环永远不会退出。因为线程在执行while()循环时,已经在getTask()入口获取了this锁,其他线程根本无法调用addTask(),因为addTask()执行条件也是获取this锁。

    1. 结合一个完整的例子总结下wait()notify()/notifyAll()的特性
    @SpringBootApplication
    public class DemoApplication implements CommandLineRunner {
    
        public static void main(String[] args) {
            SpringApplication.run(DemoApplication.class, args);
        }
    
        @Override
        public void run(String... args) throws Exception {
    
            TaskQueue q = new TaskQueue();
            List<Thread> ts = new ArrayList<>();
            for (int i = 0; i < 3; i++) {
                Thread t = new Thread(() -> {
                    try {
                        String s = q.getTask();
                    } catch (InterruptedException e) {
                        System.out.println(Thread.currentThread().getId() + " is interrupted ");
                        return;
                    }
                }, "tGet" + i);
                t.start();
                ts.add(t);
            }
            Thread add = new Thread(() -> {
                for (int i = 0; i < 2; i++) {
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                    }
                    String s = "t-" + Math.random();
                    System.out.println(LocalTime.now() + " add element: " + s);
                    q.addTask(s);
    
                }
            }, "tAdd");
            add.start();
            add.join();
            Thread.sleep(100);
            for (Thread t : ts) {
                t.interrupt();
            }
            System.out.println("main function end");
    }    
    
    public class TaskQueue {
        Queue<String> queue = new LinkedList<>();
    
        public synchronized void addTask(String s) {
            this.queue.add(s);
            this.notifyAll();
        }
    
        public synchronized String getTask() throws InterruptedException {
            while (queue.isEmpty()) {
                this.wait();
            }
            return queue.remove();
        }
    }
    
    • 看下这段代码会怎么执行, 这里要关注调用wait()的3个线程状态的变化, 以及this锁的持有者
    1. 主线程循环发起3个线程, 调用getTask() 开始等待, getTask()方法会调用wait(), 因此这3个线程会依次获取/释放掉this锁, 状态Runnable -> Waiting
    2. 主线程循环发起2个线程, 每隔5s调用一次addTask(), addTask()方法会获取this锁, 加入1个元素, 以及调用notifyAll()方法唤醒所有等待的线程, 最终释放锁
    3. 加入第一个元素, 调用notifyAll()方法, 唤醒所有线程, 被唤醒的3个线程开始竞争锁, 它们的状态Waiting -> Blocked
    4. 拿到锁的线程, 跳出while循环 remove()元素返回后,getTask()方法调用结束,this锁释放, 该线程的状态Blocked -> Runnable -> Terminated
    5. 没拿到锁的另外2个线程进入下一次 while循环 继续等待 它俩的状态 Blocked -> Waiting
    6. 继续加入第二个元素, 状况和上面两步雷同
    7. 最后, 有3个线程等待获取元素,但我们一共只加入了2个,最终会有1个线程等不到元素还在Waiting,他会被主线程interrupt
    • 可以加个sleep()方便观察到Blocked状态: stackoverflow.com/q/76748466/…
    • 可见wait()notify()/notifyAll()用法较为繁琐, 稍不注意就会出问题
  1. 常见关键字
  • sycronized:

属于可重入锁:

JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。

由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized块,记录-1,减到0的时候,才会真正释放锁。

sycronized修饰的实例方法, 等价于sycronized(this){ ... }, 对于静态方法则是sycronized(Foo.class){ ... }, 任何一个类都有一个由JVM自动创建的Class实例

在使用synchronized的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁

  • volatile: 在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的

TODO 安全点

TODO 堆栈一致性

Java多线程基本概念, 常用关键字及常用锁类

因此,volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值;
  • 每次修改变量后,立刻回写到主内存。

volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。

如果我们去掉volatile关键字,运行上述程序,发现效果和带volatile差不多,这是因为在x86的架构下,JVM回写主内存的速度非常快,但是,换成ARM的架构,就会有显著的延迟。

转载自:https://juejin.cn/post/7258896295616315450
评论
请登录