前言
一个程序在运行起来的时候会转换成进程,通常含有多个线程。通常情况下,一个进程中的比较耗时的操作(如长循环、文件上传下载、网络资源获取等),往往会采用多线程来解决。
比如显示生活中,银行取钱问题、火车票多个售票窗口的问题,通常会涉及到并发的问题,从而需要多线程的技术。
当进程中有多个并发线程进入一个重要数据的代码块时,在修改数据的过程中,很有可能引发线程安全问题,从而造成数据异常。例如,正常逻辑下,同一个编号的火车票只能售出一次,却由于线程安全问题而被多次售出,从而引起实际业务异常。
一般我们常说某某类是线程安全的,某某是非线程安全的。其实线程安全并不是一个“非黑即白”单项选择题。按照“线程安全”的安全程度由强到弱来排序,我们可以将java语言中各种操作共享的数据分为以下5类:
public class ThreadSynchronizedSecurity {
static int tickets = 15;
class SellTickets implements Runnable {
@Override
public void run() {
while (tickets > 0) {
// 同步代码块
synchronized (this) {
if (tickets <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "--->售出第: " + tickets + " 票");
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
tickets--;
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName() + "--->售票结束!");
}
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadSynchronizedSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "1号窗口");
Thread thread2 = new Thread(sell, "2号窗口");
Thread thread3 = new Thread(sell, "3号窗口");
Thread thread4 = new Thread(sell, "4号窗口");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
备注:
在使用synchronized 代码块时,可以与wait()、notify()、nitifyAll()一起使用,从而进一步实现线程的通信。其中wait()方法会释放占有的对象锁,当前线程进入等待池,释放cpu,而其他正在等待的线程即可抢占此锁,获得锁的线程即可运行程序;线程的sleep()方法则表示,当前线程会休眠一段时间,休眠期间,会暂时释放cpu,但并不释放对象锁,也就是说,在休眠期间,其他线程依然无法进入被同步保护的代码内部,当前线程休眠结束时,会重新获得cpu执行权,从而执行被同步保护的代码。wait()和sleep()最大的不同在于wait()会释放对象锁,而sleep()不会释放对象锁。
notify()方法会唤醒因为调用对象的wait()而处于等待状态的线程,从而使得该线程有机会获取对象锁。调用notify()后,当前线程并不会立即释放锁,而是继续执行当前代码,直到synchronized中的代码全部执行完毕,才会释放对象锁。JVM会在等待的线程中调度一个线程去获得对象锁,执行代码。
需要注意的是,wait()和notify()必须在synchronized代码块中调用。notifyAll()是唤醒所有等待的线程
接下来,我们通过下一个程序,使得两个线程交替打印“A”和“B”各10次。请见下述实例代码:
package com.my.annotate.thread;
public class ThreadDemo {
static final Object obj = new Object();
//第一个子线程
static class ThreadA implements Runnable {
@Override
public void run() {
int count = 5;
while (count > 0) {
synchronized (ThreadDemo.obj) {
System.out.println("A-----" + count);
count--;
synchronized (ThreadDemo.obj) {
//notify()方法会唤醒因为调用对象的wait()而处于等待状态的线程,从而使得该线程有机会获取对象锁。
//调用notify()后,当前线程并不会立即释放锁,而是继续执行当前代码,直到synchronized中的代码全部执行完毕,
ThreadDemo.obj.notify();
try {
ThreadDemo.obj.wait();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
}
}
static class ThreadB implements Runnable {
@Override
public void run() {
int count = 5;
while (count > 0) {
synchronized (ThreadDemo.obj) {
System.out.println("B-----" + count);
count--;
synchronized (ThreadDemo.obj) {
//notify()方法会唤醒因为调用对象的wait()而处于等待状态的线程,从而使得该线程有机会获取对象锁。
//调用notify()后,当前线程并不会立即释放锁,而是继续执行当前代码,直到synchronized中的代码全部执行完毕,
ThreadDemo.obj.notify();
try {
ThreadDemo.obj.wait();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
}
}
public static void main(String[] args) {
new Thread(new ThreadB()).start();
new Thread(new ThreadA()).start();
}
}
第二种实现线程安全的方式
同步方法
package com.my.annotate.thread;
public class ThreadSynchroniazedMethodSecurity {
static int tickets = 15;
class SellTickets implements Runnable {
@Override
public void run() {
//同步方法
while (tickets > 0) {
synMethod();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName() + "--->售票结束");
}
}
}
synchronized void synMethod() {
synchronized (this) {
if (tickets <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "---->售出第 " + tickets + " 票 ");
tickets--;
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadSynchroniazedMethodSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "1号窗口");
Thread thread2 = new Thread(sell, "2号窗口");
Thread thread3 = new Thread(sell, "3号窗口");
Thread thread4 = new Thread(sell, "4号窗口");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
第三种实现线程安全的方式
Lock锁机制, 通过创建Lock对象,采用lock()加锁,unlock()解锁,来保护指定的代码块
package com.my.annotate.thread;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadLockSecurity {
static int tickets = 15;
class SellTickets implements Runnable {
Lock lock = new ReentrantLock();
@Override
public void run() {
// Lock锁机制
while (tickets > 0) {
try {
lock.lock();
if (tickets <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "--->售出第: " + tickets + " 票");
tickets--;
} catch (Exception e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
} finally {
lock.unlock();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName() + "--->售票结束!");
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadLockSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "1号窗口");
Thread thread2 = new Thread(sell, "2号窗口");
Thread thread3 = new Thread(sell, "3号窗口");
Thread thread4 = new Thread(sell, "4号窗口");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
由于synchronized是在JVM层面实现的,因此系统可以监控锁的释放与否;而ReentrantLock是使用代码实现的,系统无法自动释放锁,需要在代码中的finally子句中显式释放锁lock.unlock()。另外,在并发量比较小的情况下,使用synchronized是个不错的选择;但是在并发量比较高的情况下,其性能下降会很严重,此时ReentrantLock是个不错的方案。
二、线程安全的实现方法
保证线程安全以是否需要同步手段分类,分为同步方案和无需同步方案。
1、互斥同步
互斥同步是最常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用(同一时刻,只有一个线程在操作共享数据)。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。
在java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码质量,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
此外,ReentrantLock也是通过互斥来实现同步。在基本用法上,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性。
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确地同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁。
2、非阻塞同步
随着硬件指令集的发展,出现了基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施。(最常见的补偿错误就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。
非阻塞的实现CAS(compareandswap):CAS指令需要有3个操作数,分别是内存地址(在java中理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,CAS指令指令时,当且仅当V处的值符合旧预期值A时,处理器用B更新V处的值,否则它就不执行更新,但是无论是否更新了V处的值,都会返回V的旧值,上述的处理过程是一个原子操作。
CAS缺点:
ABA问题:因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。
ABA问题的解决思路就是使用版本号。在变量前面追加版本号,每次变量更新的时候把版本号加一,那么A-B-A就变成了1A-2B-3C。JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
3、无需同步方案
要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步操作去保证正确性,因此会有一些代码天生就是线程安全的。
1)可重入代码
可重入代码(ReentrantCode)也称为纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。
可重入代码的特点是不依赖存储在堆上的数据和公用的系统资源、用到的状态量都是由参数中传入、不调用 非可重入的方法等。
(类比:synchronized拥有锁重入的功能,也就是在使用synchronized时,当一个线程得到一个对象锁后,再次请求此对象锁时时可以再次得到该对象的锁)
2)线程本地存储
如果一段代码中所需的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内。这样无需同步也能保证线程之间不出现数据的争用问题。
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典的Web交互模型中的“一个请求对应一个服务器线程(Thread-per-Request)”的处理方式,这种处理方式的广泛应用使得很多Web服务器应用都可以使用线程本地存储来解决线程安全问题。
三、线程的生命周期以及五种基本状态
Java线程具有五中基本状态:
新建状态(New):当线程对象创建后就是进入到了新建状态,如:Thread t = new MyThread();
就绪状态(Runnable):当调用线程对象的start()方法,线程即进入到了就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是执行了start()此线程就会执行。
运行状态(Running):当CPU调度处于就绪状态的线程的时候,此时线程才会得以真正的执行,即进入运行状态。
注:就绪状态是进入运行状态的唯一入口,也就是说线程进入运行状态的前提是已经进入到了就绪状态。
阻塞状态(Blocked):处于运行状态的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,知道进入到就绪状态,才有机会再次被CPU调用以进入到运行状态,根据产生阻塞状态的三原因,阻塞状态可以分为三种:
等待阻塞–》运行状态的线程执行wait()方法,使线程进入到阻塞状态
同步阻塞–》线程获取同步锁失败,因为同步锁被其他线程所占用,这时线程就会进入同步阻塞状态;
其他阻塞–》通过调用线程的sleep()或join()或发出了I/O请求的时候线程会进入阻塞状态,当sleep()状态超时,join()等待线程终止或者超时,或者I/O处理完毕,线程就会重新转入就绪状态。
死亡状态(Dead):线程执行完了或者因一场退出了run()方法,该线程就结束了生命周期。
参考代码见Gitee