Java必知必会之(四)--多线程全揭秘(下)

1,844 阅读15分钟

本文旨在用最通俗的语言讲述最枯燥的基本知识。

全文提纲:
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的算术都是:7000-1000,结果是剩余6000.这样子,银行对账就会出错了。

因此可见,并发编程不合理使用也会带来一些弊端,而针对多线程并发的问题,Java引入了同步监视器来解决问题:当线程要执行同步代码块/方法之前,必须先获得对同步监视器的锁定。
Java中锁用在的地方有:

  1. 代码块
  2. 方法(构造器、成员变量除外)

1.代码块同步

语法:

1synchronized (obj) {
2//同步内容(比如取钱的操作)    
3}

其中obj就是同步监视器,也就是说任何线程要进入执行该代码块之前,首先获得对obj的锁定,获得之后,其它线程就无法获取它,修改它,直到当前线程释放位置。
比如:爸爸的银行卡账户

1public BankCardAccount bankAccount;
2synchronized (bankAccount) {
3//对bankAccount的扣钱动作    
4}

当哥哥和妹妹同时取钱时,就如同两个线程在执行,当其中一个线程获取到对bankAccount的锁定时,另一个线程必须等待当前线程用完之后释放bankAccount的锁定,才可以获得并且修改之

2.方法同步

语法:

1修饰符 synchronized 返回值 方法名(形参列表){
2}

方法的同步不需要显示指定同步监视器,因为它的同步监视器就是当前类的对象,也就是this。

3.锁释放

有锁定就需要有释放,同步监视器的锁释放的事件有以下情况:

  1. 线程的同步块/方法执行结束
  2. 线程的同步块/方法执行过程中抛出异常或者出现ERROR
  3. 线程的同步块/方法中执行到return、break之类的终止代码
  4. 线程的同步块/方法中执行了同步监视器对象的wait()方法

而不释放的事件也有如下:

  1. 线程的同步块/方法中执行时,程序中执行了带有sleep()\yield()等暂停操作。
  2. 线程的同步块/方法中执行时,调用了suspend()挂起线程。

4. 同步锁

对于基本的同步问题,synchronized就可以满足,但是需要对线程的同步有更强大的操作,就需要到同步锁Lock了
Lock是控制多线程对共享资源进行访问的工具,通常,所提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前首先要获得Lock对象。
Lock针对不同的使用场景提供了多种类/接口,主要有以下:

  1. Lock
  2. ReentrantLock
  3. ReadWriteLock
  4. ReentrantReadWriteLock
1. Lock接口

Lock接口提供了几个方法来操作锁:

 1package java.util.concurrent.locks;
2import java.util.concurrent.TimeUnit;
3//Lock接口
4public interface Lock {
5    //获取锁。如果锁已被其他线程获取,则进行等待
6    void lock();
7    //获取锁,在等待过程中可以相应中断等待状态
8    void lockInterruptibly() throws InterruptedException;
9    //尝试获取锁,返回true为获得成功,返回false为获取失败
10    //它和lock()不一样的是,它不会一直等待,而是尝试获取,立即返回
11    boolean tryLock();
12    //尝试获得锁,如果获取不到就等待time时间
13    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
14    //释放锁
15    void unlock();
16}
2. ReentrantLock

可重入锁。意思是同一个线程可以多次获取同一个锁,虽然synchronized也属于可重入锁,但是synchronized是在获取锁的过程中是不可中断的,而ReentrantLock则可以。
ReentrantLock是唯一实现了Lock接口的类,因此我们在可以这样创建一个Lock对象:

1Lock l=new ReentrantLock();

ReentrantLock的默认状态和synchronized获得的属于非公平锁(抢占式获得锁,先等待(调用lock())的线程不一定先获得锁,而公平锁则是先获得lock的线程现货的锁)。但是ReentrantLock可以设置为公平锁,如:

1//公平锁
2Lock l1=new ReentrantLock(true);
3//非公平锁
4Lock l2=new ReentrantLock(false);
3. ReadWriteLock

顾名思义,它叫做读写锁,是一个接口,用来管理读锁和写锁,读锁也叫共享锁,也就是说读锁可以被多个线程共享,写锁也称排他锁,意思是,当一个线程获得了写锁,其它线程只能等待,不能共享。
前面我们说到:多线程并发带来同步问题,而同步问题用同步监听器来解决问题。
但我们发现有这样的一个怪圈:

多线程为了提高程序执行效率,同步监听器为了是多线程执行时有且只有其中一个线程能执行synchronized修饰的代码块或者方法,这两个东西有着此消彼长的关系.
那么?怎么样才能让多线程能愉快的行走,而同步问题有可以尽可能少的出现呢?

其实读写锁在一定程度上能解决这个难题。它的特性是:

  1. 读读共享
  2. 读写互斥
  3. 写写互斥

也就是说,比如程序开多个线程对一个文件进行读写操作时,如果用synchronized,则读写操作要互相等待,而有了ReadWriteLock之后
我们可以把读写的锁操作分开,读文件操作用读锁,写文件操作用写锁,
这样就可以快运行效率了。

我们来看它的源码:

1public interface ReadWriteLock {
2 //获取读锁
3 Lock readLock();
4 //获取写锁    
5 Lock writeLock();
6}

只有一个获取读锁和一个获取写锁的接口方法,接口的存在得有有类实现它才有意义,我们看下一个类:

4. ReentrantReadWriteLock

ReentrantReadWriteLock是ReadWriteLock接口的实现类,当我们要创建一个ReadWriteLock的锁时,通常:

1ReadWriteLock rl=new ReentrantReadWriteLock();

前面说到ReentrantLock是Lock的实现类,ReentrantLock是一种排它锁,也就是说某个时间内,只有允许一个线程访问(但是这个线程可以同时访问多次),而ReentrantLock是读写锁,也就是说在同一时间内,允许多个线程同时获取读锁进行操作(但不允许读写、写写同时操作),在某些业务场景(比如读操作远高于写操作)下,ReentrantReadWriteLock会比ReentrantLock有更好的性能和并发。
ReentrantReadWriteLock主要有以下特效:

  1. 可以设置公平锁和非公平锁。
1//公平锁
2ReadWriteLock rl=new ReentrantReadWriteLock(true);
3//非公平锁
4ReadWriteLock rl=new ReentrantReadWriteLock();
  1. 可重入锁。
    2.1 同一个读线程可多次获得读锁
    2.2 同一个写线程可以多次获得写锁或者读锁
  2. 可中断性:就是说可以在获取锁期间中断操作
  3. 可以锁降级:也就是写锁可降为读锁

7. 线程通信

当线程在程序中执行时,线程的调度有一些不确定性,也就是在常规情况无法准确的控制线程之间的轮换执行时机,因此Java提供了一些机制来便于开发者控制线程的协调运行。

  1. synchronized修饰的方法/代码块中使用wait()、notify()、notifyAll()来协调
  2. 使用condition控制
  3. 使用阻塞队列控制
1. synchronized修饰方法/代码块中使用wait()、notify()、notifyAll()协调

实际上,wait、notify、notifyAll是定义在Object类的实例方法他们只能在synchronized的代码块/方法中使用,用来控制线程。

  1. wait: 持有锁的线程准备释放对象锁权限,释放cpu资源并进入等待。
  2. notify:持有对象锁的线程1即将释放锁,通知jvm唤醒某个竞争该锁的线程2。线程在 synchronized 代码作用域结束后,线程2直接获得锁,其他竞争线程继续等待(即使线程X同步完毕,释放对象锁,其他竞争线程仍然等待,直至有新的notify ,notifyAll被调用)。
  3. notifyAll:持有锁的线程1准备释放锁,通知jvm唤醒所有竞争该锁的线程,线程1在synchronized 代码作用域结束后,jvm通过算法将对象锁权限指派给某个线程2,所有被唤醒的线程不再等待。线程1在synchronized 代码作用域结束后,之前所有被唤醒的线程都有可能获得该对象锁权限,这个由JVM算法决定。
2. 使用condition控制

对于用Lock来做同步工作的情况,Java提供了condition类来协助控制线程通信。condition的实例是由Lock对象来创建的,

1//创建一个lock对象
2Lock l=new ReentrantLock();
3//创建一个condition实例
4Condition con=l.newCondition();

Condition类有以下方法:

  1. await():类似于wait(),导致当前线程等待,知道其它线程代用该Condition的signal()或signalAll()来唤醒该线程
  2. signal():唤醒此Lock对象上等待的单个线程,如果所有线程都在该Lock对象上等待,则会选择唤醒其中一个线程,选择是任意的,只有当前线程放弃对该Lock对象的锁定后才可以执行被唤醒的线程
  3. signalAll():唤醒在此Lock对象上等待的所有线程,只有当前线程放弃对该Lock对象的锁定后,才可以执行被唤醒的线程。
3. 使用阻塞队列控制

在Java5中提供了一个接口:BlockingQueue,它是作为线程同步的一个工具而产生,当生产者线程试图向BlockingQueue中放入元素时,如果该队列已满,则线程被阻塞,当消费者线程试图从BlockingQueue中取出元素时,如果队列为空,则线程被阻塞。
BlockingQueue接口源码:

 1public interface BlockingQueue<Eextends Queue<E{
2    boolean add(E e);
3    boolean offer(E e);
4    void put(E e) throws InterruptedException;
5    boolean offer(E e, long timeout, TimeUnit unit)
6        throws InterruptedException
;
7    take() throws InterruptedException;
8    poll(long timeout, TimeUnit unit)
9        throws InterruptedException
;
10    int remainingCapacity();
11    boolean remove(Object o);
12    public boolean contains(Object o);
13    int drainTo(Collection<? super E> c);
14    int drainTo(Collection<? super E> c, int maxElements);
15}

其中支持阻塞的有两个:

  1. take():尝试从BlockingQueue头部获取元素
  2. put(E e):尝试把e放入BlockingQueue中

BlockingQueue接口的实现类有:

  1. ArrayBlockingQueue:数组阻塞队列
  2. LinkedBlockingQueue:链表阻塞队列
  3. PriorityBlockingQueue:带有排序性的非标准阻塞队列
  4. SynchronousQueue:同步队列,读写不能同时,只能交替执行
  5. DelayQueue:特殊的阻塞队列,它要求集合元素都实现Dely接口

阻塞队列平时用得少,就仅仅讲述一些基本原理和使用方法,例子不再赘述。

8. 线程池

线程池的产生和数据库的连接池类似,系统启动一个线程的代价是比较高昂的,如果在程序启动的时候就初始化一定数量的线程,放入线程池中,在需要是使用时从池子中去,用完再放回池子里,这样能大大的提高程序性能,再者,线程池的一些初始化配置,也可以有效的控制系统并发的数量。
Java提供了一个Executors工厂类来创建线程池,要新建一个线程池,主要有以下几个静态方法:

  1. newFixedThreadPool:可重用、有固定线程数的池子
  2. newCachedThreadPool:带有缓存的池子
  3. newSingleThreadExecutor:只有一个线程的池子
  4. newScheduledThreadPool:可指定延后执行的池子

关于每个方法具体使用以及参数,再次就不赘述了,有兴趣的筒子直接进入Executors类就可以看到了。

9. 线程安全

什么是线程安全?
在多线程环境下,多个线程同时访问共享数据时,某个线程访问的被其它线程修改了,导致它使用了错误的数据而产生了错误,这就引发了线程的不安全问题。
而当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些进程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
大家是否记得,不管是老师的课后习题还是面试笔试题,经常都会出现“StringBuilder、StringBuffer是否线程安全”这样的问题?
我们来查看各自的源码看看究竟吧。
StringBuffer的append方法:

1@Override
2    public synchronized StringBuffer append(String str) {
3        toStringCache = null;
4        super.append(str);
5        return this;
6    }

StringBuilder的append方法:

1 @Override
2    public StringBuilder append(String str) {
3        super.append(str);
4        return this;
5    }

再看看它们的super.append源码:

1public AbstractStringBuilder append(String str) {
2        if (str == null)
3            return appendNull();
4        int len = str.length();
5        ensureCapacityInternal(count + len);
6        str.getChars(0, len, value, count);
7        count += len;
8        return this;
9    }

可以看出,两者的append方法区别就在于前者有synchronized修饰,这意味着多个线程可以同时访问这个方法时,前者是阻塞运行的,而后者是可以同时运行并且同时访问count,因此就有可能导致count错乱。由此可见:

StringBuffer 是线程安全的,但是由于加了锁,导致效率变低。
StringBuilder 是线程不安全的,在单线程环境下,效率非常高。

既然已经从根本知道了什么是线程安全,那么Java是如何解决线程安全问题的呢?
从Java5开始,增加一了些线程安全的类来处理线程安全的问题,如:

  1. ThreadLocal
  2. ConcurrentHashMap
  3. ConcurrentSkipListMap
  4. ConcurrentSkipListSet
  5. ConcurrentLinkedQueue
  6. ConcurrentLinkedDeque
  7. CopyOnWriteArrayList
  8. CopyOnWriteArrayList
  9. CopyOnWriteArraySet
  10. CopyOnWriteHashMap

10. ThreadLocal

ThreadLocal代表一个线程局部变量,通过把数据放在ThreadLocal中就可以让每个线程创建一个该变量的副本,从未避免并发访问的线程安全问题。
维持线程封闭性的一种方法是使用ThreadLocal。它提供了set和get等访问方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get方法总是返回由当前执行线程在调用set时设置的最新值。
它提供三个方法:

  1. T get():返回此线程局部变量中当前线程副本中的值。
  2. remove():删除此线程局部变量中当前线程的值。
  3. set(T t):设置此线程局部变量中当前线程副本中的值。

举个栗子:创建一个带有ThreadLocal的类:

 1public class TestThreadLocal  {
2    // 副本
3    private ThreadLocal<Integer> countLoacl = new ThreadLocal<Integer>();
4    public TestThreadLocal(Integer num) {
5        countLoacl.set(num);
6    }
7    public Integer getCount() {
8        return countLoacl.get();
9    }
10    public void setCount(Integer num) {
11        countLoacl.set(num);
12    }
13}

这样子创建的类带有ThreadLocal的countLoacl,在多个线程同时消费这个对象时,ThreadLocal会为每个线程创建一个countLoacl副本,这样就可以避免多线程之间的资源竞争而导致安全问题了。

觉得本文对你有帮助?请分享给更多人 关注「编程无界」,提升装逼技能