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

3476 篇文章 105 订阅

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

  1. 线程的状态
  • New:新创建的线程,尚未执行;
  • Runnable:运行中的线程,正在执行run()方法的Java代码;
  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;
  • Waiting:运行中的线程,因为某些操作在等待中;
  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
  • Terminated:线程已终止,因为run()方法执行完毕。
  • 放两张图帮助理解:

n2U3N.png

image.png

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

Blocked: 等待同步锁(syncronized, ReentrantLock), 处于锁竞争状态, 但未获取到锁 Waiting: 等待其他线程执行完成(join(), wait()), 会将持有的锁释放

  1. 守护线程
  • 所有非守护线程都执行完毕后,无论有没有守护线程,JVM都会自动退出。
  • 使用守护线程的一个常见例子是在后台执行周期性的任务,例如GC, 定时任务或日志清理等。
  1. 线程同步
  • 临界区: 加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。

对于语句:

java复制代码n = n + 1;

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

assembly复制代码ILOAD
IADD
ISTORE

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

image.png

 

加锁后:

image.png

 

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

基本类型(long和double除外)赋值,例如:int n = m;

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

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

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

  1. 死锁

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

java复制代码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()方法如下:

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

优点:

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

缺点:

需要在finally代码块手动调用unlock() 需要自己处理异常

Condition: 可以从Lock对象的实例获取其Condition(其它比如ReadWriteLock是没有Condition的),Condition提供的await()、signal()、signalAll()原理和synchronized锁对象的wait()、notify()、notifyAll()是一致的,并且其行为也是一样的

java复制代码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()唤醒,可以自己醒来

java复制代码

if (condition.await(1, TimeUnit.SECOND)) { // 被其他线程唤醒 } else { // 指定时间内没有被其他线程唤醒 }

  • ReadWriteLock: 悲观读锁,可重入锁. 把读写操作分别用读锁和写锁来加锁, 允许多个线程同时读(当有一个线程持有读锁, 其他线程可以获取读锁, 这样就大大提高了并发读的执行效率), 但它只允许一个线程写入(当有一个线程持有写锁, 其他线程读锁和写锁都获取不到)
java复制代码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: 乐观读锁,不可重入锁.StampedLock和ReadWriteLock相比,不同之处在于: 读的过程中也允许获取写锁后写入,这样一来,我们读的数据就可能不一致,但需要一点额外的代码来判断读的过程中是否有写入
java复制代码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;
java复制代码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类的方法, 必须在已获得的锁对象上调用它们
  • synchronized解决了多线程竞争的问题, 但没有解决并没有解决多线程协调的问题
  • java复制代码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锁。
  • 结合一个完整的例子总结下wait()和notify()/notifyAll()的特性
  • java复制代码@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锁的持有者
  • 主线程循环发起3个线程, 调用getTask() 开始等待, getTask()方法会调用wait(), 因此这3个线程会依次获取/释放掉this锁, 状态Runnable -> Waiting
  • 主线程循环发起2个线程, 每隔5s调用一次addTask(), addTask()方法会获取this锁, 加入1个元素, 以及调用notifyAll()方法唤醒所有等待的线程, 最终释放锁
  • 加入第一个元素, 调用notifyAll()方法, 唤醒所有线程, 被唤醒的3个线程开始竞争锁, 它们的状态Waiting -> Blocked
  • 拿到锁的线程, 跳出while循环 remove()元素返回后,getTask()方法调用结束,this锁释放, 该线程的状态Blocked -> Runnable -> Terminated
  • 没拿到锁的另外2个线程进入下一次 while循环 继续等待 它俩的状态 Blocked -> Waiting
  • 继续加入第二个元素, 状况和上面两步雷同
  • 最后, 有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 堆栈一致性

image.png

 

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

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

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值