本文旨在用最通俗的语言讲述最枯燥的基本知识.
全文提纲:
1. 线程是什么(上)
2. 线程和进程的区别和联系(上)
3. 创建多线程的方法(上)
4. 线程的生命周期(上)
5. 线程的控制(上)
6. 线程同步(下)
7. 线程池(下)
8. 线程通信(下)
9. 线程安全(下)
10.ThreadLocal 的基本用法(下)
上集已经讲述了 Java 线程的一些基本概念, 本文接下来讲述的是 Java 的一些高级应用.
6. 线程同步
一开始接触 "线程同步" 这个概念可以有点难以理解, 我们来举个栗子:
爸爸开了一张银行卡存进去 10000 块钱, 是留给在山东读大学的哥哥和在河南老家读高中的妹妹用的. 哥哥前天取了 2000, 变成 8000, 妹妹昨天取了 1000, 剩余 7000, 今天他们同时到银行同时取钱, 哥哥打开时 ATM 发现有 7000 余额, 妹妹打开时也发现是 7000 余额, 他们同时按下确定取 1000 钱, 当他们取完钱之后在查看余额发现只有 5000 块钱, 都在想我只取了 1000 啊怎么扣了我 2000 呢
这就是生活中的 "同步" 问题了.
我们把思维转入到这个 ATM 的后台程序, 幸好后台程序对取钱的操作做了同步动作的监听器, 能在多线程同时操作的过程中把取钱的动作给锁定起来, 如果程序没有处理同步问题, 那两边的 ATM 的算术都是: 70001000, 结果是剩余 6000. 这样子, 银行对账就会出错了.
因此可见, 并发编程不合理使用也会带来一些弊端, 而针对多线程并发的问题, Java 引入了同步监视器来解决问题: 当线程要执行同步代码块 / 方法之前, 必须先获得对同步监视器的锁定.
Java 中锁用在的地方有:
代码块
方法(构造器, 成员变量除外)
1. 代码块同步
语法:
- synchronized (obj) {
- // 同步内容(比如取钱的操作)
- }
复制代码
其中 obj 就是同步监视器, 也就是说任何线程要进入执行该代码块之前, 首先获得对 obj 的锁定, 获得之后, 其它线程就无法获取它, 修改它, 直到当前线程释放位置.
比如: 爸爸的银行卡账户
- public BankCardAccount bankAccount;
- synchronized (bankAccount) {
- // 对 bankAccount 的扣钱动作
- }
复制代码
当哥哥和妹妹同时取钱时, 就如同两个线程在执行, 当其中一个线程获取到对 bankAccount 的锁定时, 另一个线程必须等待当前线程用完之后释放 bankAccount 的锁定, 才可以获得并且修改之
2. 方法同步
语法:
1 修饰符 synchronized 返回值 方法名(形参列表){
2}
复制代码
方法的同步不需要显示指定同步监视器, 因为它的同步监视器就是当前类的对象, 也就是 this.
3. 锁释放
有锁定就需要有释放, 同步监视器的锁释放的事件有以下情况:
线程的同步块 / 方法执行结束
线程的同步块 / 方法执行过程中抛出异常或者出现 ERROR
线程的同步块 / 方法中执行到 return,break 之类的终止代码
线程的同步块 / 方法中执行了同步监视器对象的 wait()方法
而不释放的事件也有如下:
线程的同步块 / 方法中执行时, 程序中执行了带有 sleep()\yield()等暂停操作.
线程的同步块 / 方法中执行时, 调用了 suspend()挂起线程.
4. 同步锁
对于基本的同步问题, synchronized 就可以满足, 但是需要对线程的同步有更强大的操作, 就需要到同步锁 Lock 了
Lock 是控制多线程对共享资源进行访问的工具, 通常, 所提供了对共享资源的独占访问, 每次只能有一个线程对 Lock 对象加锁, 线程开始访问共享资源之前首先要获得 Lock 对象.
Lock 针对不同的使用场景提供了多种类 / 接口, 主要有以下:
- Lock
- ReentrantLock
- ReadWriteLock
- ReentrantReadWriteLock
1. Lock 接口
Lock 接口提供了几个方法来操作锁:
- package java.util.concurrent.locks;
- import java.util.concurrent.TimeUnit;
- //Lock 接口
- public interface Lock {
- // 获取锁. 如果锁已被其他线程获取, 则进行等待
- void lock();
- // 获取锁, 在等待过程中可以相应中断等待状态
- void lockInterruptibly() throws InterruptedException;
- // 尝试获取锁, 返回 true 为获得成功, 返回 false 为获取失败
- // 它和 lock()不一样的是, 它不会一直等待, 而是尝试获取, 立即返回
- boolean tryLock();
- // 尝试获得锁, 如果获取不到就等待 time 时间
- boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
- // 释放锁
- void unlock();
- }
复制代码
2. ReentrantLock
可重入锁. 意思是同一个线程可以多次获取同一个锁, 虽然 synchronized 也属于可重入锁, 但是 synchronized 是在获取锁的过程中是不可中断的, 而 ReentrantLock 则可以.
ReentrantLock 是唯一实现了 Lock 接口的类, 因此我们在可以这样创建一个 Lock 对象:
1Lock l=new ReentrantLock();
复制代码
ReentrantLock 的默认状态和 synchronized 获得的属于非公平锁 (抢占式获得锁, 先等待(调用 lock()) 的线程不一定先获得锁, 而公平锁则是先获得 lock 的线程现货的锁). 但是 ReentrantLock 可以设置为公平锁, 如:
- // 公平锁
- Lock l1=new ReentrantLock(true);
- // 非公平锁
- Lock l2=new ReentrantLock(false);
复制代码
3. ReadWriteLock
顾名思义, 它叫做读写锁, 是一个接口, 用来管理读锁和写锁, 读锁也叫共享锁, 也就是说读锁可以被多个线程共享, 写锁也称排他锁, 意思是, 当一个线程获得了写锁, 其它线程只能等待, 不能共享.
前面我们说到: 多线程并发带来同步问题, 而同步问题用同步监听器来解决问题.
但我们发现有这样的一个怪圈:
多线程为了提高程序执行效率, 同步监听器为了是多线程执行时有且只有其中一个线程能执行 synchronized 修饰的代码块或者方法, 这两个东西有着此消彼长的关系.
那么 怎么样才能让多线程能愉快的行走, 而同步问题有可以尽可能少的出现呢
其实读写锁在一定程度上能解决这个难题. 它的特性是:
读读共享
读写互斥
写写互斥
也就是说, 比如程序开多个线程对一个文件进行读写操作时, 如果用 synchronized, 则读写操作要互相等待, 而有了 ReadWriteLock 之后
我们可以把读写的锁操作分开, 读文件操作用读锁, 写文件操作用写锁,
这样就可以快运行效率了.
我们来看它的源码:
- public interface ReadWriteLock {
- // 获取读锁
- Lock readLock();
- // 获取写锁
- Lock writeLock();
- }
复制代码
只有一个获取读锁和一个获取写锁的接口方法, 接口的存在得有有类实现它才有意义, 我们看下一个类:
4. ReentrantReadWriteLock
ReentrantReadWriteLock 是 ReadWriteLock 接口的实现类, 当我们要创建一个 ReadWriteLock 的锁时, 通常:
1ReadWriteLock rl=new ReentrantReadWriteLock();
复制代码
前面说到 ReentrantLock 是 Lock 的实现类, ReentrantLock 是一种排它锁, 也就是说某个时间内, 只有允许一个线程访问 (但是这个线程可以同时访问多次), 而 ReentrantLock 是读写锁, 也就是说在同一时间内, 允许多个线程同时获取读锁进行操作(但不允许读写, 写写同时操作), 在某些业务场景(比如读操作远高于写操作) 下, ReentrantReadWriteLock 会比 ReentrantLock 有更好的性能和并发.
ReentrantReadWriteLock 主要有以下特效:
可以设置公平锁和非公平锁.
- // 公平锁
- ReadWriteLock rl=new ReentrantReadWriteLock(true);
- // 非公平锁
- ReadWriteLock rl=new ReentrantReadWriteLock();
复制代码
可重入锁.
2.1 同一个读线程可多次获得读锁
2.2 同一个写线程可以多次获得写锁或者读锁
可中断性: 就是说可以在获取锁期间中断操作
可以锁降级: 也就是写锁可降为读锁
7. 线程通信
当线程在程序中执行时, 线程的调度有一些不确定性, 也就是在常规情况无法准确的控制线程之间的轮换执行时机, 因此 Java 提供了一些机制来便于开发者控制线程的协调运行.
synchronized 修饰的方法 / 代码块中使用 wait(),notify(),notifyAll()来协调
使用 condition 控制
使用阻塞队列控制
1. synchronized 修饰方法 / 代码块中使用 wait(),notify(),notifyAll()协调
实际上, wait,notify,notifyAll 是定义在 Object 类的实例方法他们只能在 synchronized 的代码块 / 方法中使用, 用来控制线程.
wait: 持有锁的线程准备释放对象锁权限, 释放 cpu 资源并进入等待.
notify: 持有对象锁的线程 1 即将释放锁, 通知 jvm 唤醒某个竞争该锁的线程 2. 线程在 synchronized 代码作用域结束后, 线程 2 直接获得锁, 其他竞争线程继续等待(即使线程 X 同步完毕, 释放对象锁, 其他竞争线程仍然等待, 直至有新的 notify ,notifyAll 被调用).
notifyAll: 持有锁的线程 1 准备释放锁, 通知 jvm 唤醒所有竞争该锁的线程, 线程 1 在 synchronized 代码作用域结束后, jvm 通过算法将对象锁权限指派给某个线程 2, 所有被唤醒的线程不再等待. 线程 1 在 synchronized 代码作用域结束后, 之前所有被唤醒的线程都有可能获得该对象锁权限, 这个由 JVM 算法决定.
2. 使用 condition 控制
对于用 Lock 来做同步工作的情况, Java 提供了 condition 类来协助控制线程通信. condition 的实例是由 Lock 对象来创建的,
- // 创建一个 lock 对象
- Lock l=new ReentrantLock();
- // 创建一个 condition 实例
- Condition con=l.newCondition();
复制代码
Condition 类有以下方法:
await(): 类似于 wait(), 导致当前线程等待, 知道其它线程代用该 Condition 的 signal()或 signalAll()来唤醒该线程
signal(): 唤醒此 Lock 对象上等待的单个线程, 如果所有线程都在该 Lock 对象上等待, 则会选择唤醒其中一个线程, 选择是任意的, 只有当前线程放弃对该 Lock 对象的锁定后才可以执行被唤醒的线程
signalAll(): 唤醒在此 Lock 对象上等待的所有线程, 只有当前线程放弃对该 Lock 对象的锁定后, 才可以执行被唤醒的线程.
3. 使用阻塞队列控制
在 Java5 中提供了一个接口: BlockingQueue, 它是作为线程同步的一个工具而产生, 当生产者线程试图向 BlockingQueue 中放入元素时, 如果该队列已满, 则线程被阻塞, 当消费者线程试图从 BlockingQueue 中取出元素时, 如果队列为空, 则线程被阻塞.
BlockingQueue 接口源码:
- public interface BlockingQueue<E> extends Queue<E> {
- boolean add(E e);
- boolean offer(E e);
- void put(E e) throws InterruptedException;
- boolean offer(E e, long timeout, TimeUnit unit)
- throws InterruptedException;
- E take() throws InterruptedException;
- E poll(long timeout, TimeUnit unit)
- throws InterruptedException;
- int remainingCapacity();
- boolean remove(Object o);
- public boolean contains(Object o);
- int drainTo(Collection< super E> c);
- int drainTo(Collection< super E> c, int maxElements);
- }
复制代码
其中支持阻塞的有两个:
take(): 尝试从 BlockingQueue 头部获取元素
put(E e): 尝试把 e 放入 BlockingQueue 中
BlockingQueue 接口的实现类有:
ArrayBlockingQueue: 数组阻塞队列
LinkedBlockingQueue: 链表阻塞队列
PriorityBlockingQueue: 带有排序性的非标准阻塞队列
SynchronousQueue: 同步队列, 读写不能同时, 只能交替执行
DelayQueue: 特殊的阻塞队列, 它要求集合元素都实现 Dely 接口
阻塞队列平时用得少, 就仅仅讲述一些基本原理和使用方法, 例子不再赘述.
8. 线程池
线程池的产生和数据库的连接池类似, 系统启动一个线程的代价是比较高昂的, 如果在程序启动的时候就初始化一定数量的线程, 放入线程池中, 在需要是使用时从池子中去, 用完再放回池子里, 这样能大大的提高程序性能, 再者, 线程池的一些初始化配置, 也可以有效的控制系统并发的数量.
Java 提供了一个 Executors 工厂类来创建线程池, 要新建一个线程池, 主要有以下几个静态方法:
newFixedThreadPool: 可重用, 有固定线程数的池子
newCachedThreadPool: 带有缓存的池子
newSingleThreadExecutor: 只有一个线程的池子
newScheduledThreadPool: 可指定延后执行的池子
关于每个方法具体使用以及参数, 再次就不赘述了, 有兴趣的筒子直接进入 Executors 类就可以看到了.
9. 线程安全
什么是线程安全
在多线程环境下, 多个线程同时访问共享数据时, 某个线程访问的被其它线程修改了, 导致它使用了错误的数据而产生了错误, 这就引发了线程的不安全问题.
而当多个线程访问某个类时, 不管运行时环境采用何种调度方式或者这些进程将如何交替执行, 并且在主调代码中不需要任何额外的同步或协同, 这个类都能表现出正确的行为, 那么就称这个类是线程安全的.
大家是否记得, 不管是老师的课后习题还是面试笔试题, 经常都会出现 "StringBuilder,StringBuffer 是否线程安全" 这样的问题
我们来查看各自的源码看看究竟吧.
StringBuffer 的 append 方法:
- Override
- public synchronized StringBuffer append(String str) {
- toStringCache = null;
- super.append(str);
- return this;
- }
复制代码
StringBuilder 的 append 方法:
- Override
- public StringBuilder append(String str) {
- super.append(str);
- return this;
- }
复制代码
再看看它们的 super.append 源码:
- public AbstractStringBuilder append(String str) {
- if (str == null)
- return appendNull();
- int len = str.length();
- ensureCapacityInternal(count + len);
- str.getChars(0, len, value, count);
- count += len;
- return this;
- }
复制代码
可以看出, 两者的 append 方法区别就在于前者有 synchronized 修饰, 这意味着多个线程可以同时访问这个方法时, 前者是阻塞运行的, 而后者是可以同时运行并且同时访问 count, 因此就有可能导致 count 错乱. 由此可见:
StringBuffer 是线程安全的, 但是由于加了锁, 导致效率变低.
StringBuilder 是线程不安全的, 在单线程环境下, 效率非常高.
既然已经从根本知道了什么是线程安全, 那么 Java 是如何解决线程安全问题的呢
从 Java5 开始, 增加一了些线程安全的类来处理线程安全的问题, 如:
- ThreadLocal
- ConcurrentHashMap
- ConcurrentSkipListMap
- ConcurrentSkipListSet
- ConcurrentLinkedQueue
- ConcurrentLinkedDeque
- CopyOnWriteArrayList
- CopyOnWriteArrayList
- CopyOnWriteArraySet
- CopyOnWriteHashMap
- 10. ThreadLocal
ThreadLocal 代表一个线程局部变量, 通过把数据放在 ThreadLocal 中就可以让每个线程创建一个该变量的副本, 从未避免并发访问的线程安全问题.
维持线程封闭性的一种方法是使用 ThreadLocal. 它提供了 set 和 get 等访问方法, 这些方法为每个使用该变量的线程都存有一份独立的副本, 因此 get 方法总是返回由当前执行线程在调用 set 时设置的最新值.
它提供三个方法:
T get(): 返回此线程局部变量中当前线程副本中的值.
remove(): 删除此线程局部变量中当前线程的值.
set(T t): 设置此线程局部变量中当前线程副本中的值.
举个栗子: 创建一个带有 ThreadLocal 的类:
- public class TestThreadLocal {
- // 副本
- private ThreadLocal<Integer> countLoacl = new ThreadLocal<Integer>();
- public TestThreadLocal(Integer num) {
- countLoacl.set(num);
- }
- public Integer getCount() {
- return countLoacl.get();
- }
- public void setCount(Integer num) {
- countLoacl.set(num);
- }
- }
复制代码
这样子创建的类带有 ThreadLocal 的 countLoacl, 在多个线程同时消费这个对象时, ThreadLocal 会为每个线程创建一个 countLoacl 副本, 这样就可以避免多线程之间的资源竞争而导致安全问题了.
来源: https://juejin.im/post/5b95c647e51d450e9942e43a