背景
若要实现高并发,必须保证线程安全。
一、线程共享数据与线程安全
1.数据不共享
由于java为每个对象创建各自的局部变量和空间,所以为了保证数据安全,自定义的线程类使用的资源数据不是同一个对象的即可。
//线程
public class MyThread extends Thread{
private int count = 5;
public MyThread(String name){
super();
this.setName(name);
}
@Override
public synchronized void run() {
super.run();
while (count>0){
count--;
System.out.println("新线程名字:"+Thread.currentThread().getName()+" id="+Thread.currentThread().getId()+" count的值:"+count);
}
}
}
//运行
public class Run{
public static void main(String[] args) {
//各个线程类拥有各自的资源数据
MyThread a = new MyThread("A");
MyThread b = new MyThread("B");
MyThread c = new MyThread("C");
a.start();
b.start();
c.start();
}
}
/*
新线程名字:A id=12 count的值:4
新线程名字:C id=14 count的值:4
新线程名字:B id=13 count的值:4
新线程名字:C id=14 count的值:3
新线程名字:A id=12 count的值:3
新线程名字:C id=14 count的值:2
新线程名字:B id=13 count的值:3
新线程名字:C id=14 count的值:1
新线程名字:A id=12 count的值:2
新线程名字:C id=14 count的值:0
新线程名字:B id=13 count的值:2
新线程名字:B id=13 count的值:1
新线程名字:A id=12 count的值:1
新线程名字:B id=13 count的值:0
新线程名字:A id=12 count的值:0
*/
2.数据共享
//线程
public class MyThread extends Thread{
private int count = 5;
@Override
public void run() {
super.run();
count--;
System.out.println("新线程名字:"+Thread.currentThread().getName()+" id="+Thread.currentThread().getId()+" count的值:"+count);
}
}
//
//运行
public class Run{
public static void main(String[] args) {
MyThread myThread = new MyThread();
//各个线程类拥有同一对象target
Thread a = new Thread(myThread, "A");
Thread b = new Thread(myThread, "B");
Thread c = new Thread(myThread, "C");
Thread d = new Thread(myThread, "D");
Thread e = new Thread(myThread, "E");
a.start();
b.start();
c.start();
d.start();
e.start();
}
}
/*
新线程名字:A id=13 count的值:3
新线程名字:C id=15 count的值:2
新线程名字:D id=16 count的值:1
新线程名字:E id=17 count的值:0
新线程名字:B id=14 count的值:3
*/
3.线程安全
上面多个线程同时对count进行处理产生冲突,产生了“非线程安全”问题。
public class MyThread extends Thread{
private int count = 5;
//加关键字
@Override
public synchronized void run() {
super.run();
count--;
System.out.println("新线程名字:"+Thread.currentThread().getName()+" id="+Thread.currentThread().getId()+" count的值:"+count);
}
}
/*
新线程名字:A id=13 count的值:4
新线程名字:D id=16 count的值:3
新线程名字:E id=17 count的值:2
新线程名字:C id=15 count的值:1
新线程名字:B id=14 count的值:0
*/
加上synchronized关键字上锁,使线程排队操作,可以做到线程安全。
二、线程同步的问题
当两个或以上的线程需要共享对同一数据的存取,并且每一个线程都调用了一个修改该对象状态的方法。就存在由于交叉操作而损坏数据的可能性。这种潜在的交叉操作术语上称为临界区(critical region)。
保护代码块受并发访问干扰的机制
1.锁
使用可重入锁ReentrantLock保护代码块,基本结构如下:
private Lock myLock = new ReentrantLock();
myLock.lock();//上锁
try
{
//临界区
}
finally
{
mylock.unlock();//确保即使临界区抛出异常也必须解开锁,否则,其它线程永远阻塞
}
同步与非同步的比较
代码测试:
public class ThreadTest {
public static final int account_number = 100;//100个账户
public static final double init_balance = 1000;//初始余额
public static final double max_amount = 1000;//最大转账金额
public static final int delay = 10;//延迟
public static void main(String[] args) {
//初始化100个账户,每个账户1000元
Bank bank = new Bank(account_number,init_balance);
//遍历100个账户随机向其它账户转移随机金额
for (int i =0;i<account_number;i++){
int fromAccount = i;
Runnable r = () ->{
try{
while (true){
int toAccount = (int) (bank.size()*Math.random());
double amount = max_amount*Math.random();
bank.transfer(fromAccount,toAccount,amount);
Thread.sleep((long) (delay*Math.random()));
}
}catch (InterruptedException e){
}
};
Thread t = new Thread(r);
t.start();
}
}
}
public class Bank {
public final double[] accounts;
//初始化银行类
public Bank(int n ,double initialBalance) {
accounts = new double[n];
Arrays.fill(accounts,initialBalance);
}
/**
*
* @param from 转账的来源账户
* @param to 转账的目标账户
* @param amount 转移的金额
*/
public void transfer(int from, int to, double amount){
//余额不足,则什么也不做
if (accounts[from]<amount) {
return;
};
System.out.println(Thread.currentThread());
//扣款
accounts[from] -= amount;
System.out.printf("%10.2f 从账户 %d 到账户 %d",amount,from,to);
//收款
accounts[to] += amount;
System.out.printf(" 总金额: %10.2f%n",getTotalBalance());
}
//计算银行总金额
public double getTotalBalance(){
double sum = 0;
for (double a :accounts){
sum += a;
}
return sum;
}
//获得银行账户数量
public int size(){
return accounts.length;
}
}
上述代码,意为随机账户向随机账户转账随机的金额,该操作会一直运转下去,只有CTRL+C或其它方法才能终止程序。测试发现,一段时间过后总金额发生了错误。
可见,线程发生了冲突。为了避免这种情况,我们将上述转账操作上锁放在临界区里。
private final Lock bankLock = new ReentrantLock();
public void transfer(int from, int to, double amount){
bankLock.lock();
try{
//余额不足,则什么也不做
if (accounts[from]<amount) {
return;
};
System.out.println(Thread.currentThread());
accounts[from] -= amount;
System.out.printf("%10.2f 从账户 %d 到账户 %d",amount,from,to);
accounts[to] += amount;
System.out.printf(" 总金额: %10.2f%n",getTotalBalance());
}
finally {
bankLock.unlock();
}
}
程序继续一直运转下去,测试结果:
可见,将代码上锁放在临界区,是可靠的。
代码解释:
每个Bank对象都拥有自己的ReentrantLock锁对象,拥有自己的资源accounts,当进行敏感操作transfer时,代表要操作自己的资源accounts,而这很可能引起冲突,所以在transfer方法体内立即上锁bankLock.lock()。
此时当一个线程操作Bank对象时,阻塞其它线程对该Bank对象的操作,保证安全。
而如果两个线程访问不同的Bank对象(比如new了两个Bank对象),则代表访问不同的资源,这种情况是并行的,不会发生阻塞。
ReentrantLock是可重入的,意思是某个已经持有锁的线程可再次嵌套调用被该锁保护的代码,而不会发生死锁。
例如,transfer方法体内对bankLock对象的持有计数是1,若嵌套调用getTotalBalance方法,持有计数变成2,当退出getTotalBalance方法时,持有计数变成1,当继续退出transfer方法时,持有计数变为0;这时共享对象才可被其它线程访问。
而不可重入锁,意思是持有锁的线程无法再次调用其它拥有该锁的方法,因为被阻塞了。
认识条件对象(条件变量)
通常,线程进入临界区,却发现在某一条件满足之后才能执行。
例如,上述银行转账操作,余额大于等于转账金额才能转账。
if (accounts[from]<amount) {
return;
};
我们需要将线程进入等待状态,而不是直接结束线程。这时就需要条件对象(条件变量Condition)进行线程间的通信。
在JDK5之前,是通过Object对象的**(wait、notify 和 notifyAll)方法进行线程间通信,现在Condition(await、signal和signalAll)**代替了Object监视器方法的使用。
public class Bank {
public final double[] accounts;
private final Lock bankLock;
private final Condition sufficientFunds;
//初始化银行类
public Bank(int n ,double initialBalance) {
accounts = new double[n];
Arrays.fill(accounts,initialBalance);
//创建可重入锁
bankLock = new ReentrantLock();
//创建条件对象
sufficientFunds = bankLock.newCondition();
}
/**
*
* @param from 转账的来源账户
* @param to 转账的目标账户
* @param amount 转移的金额
*/
public void transfer(int from, int to, double amount){
//加锁
bankLock.lock();
try{
while (accounts[from]<amount) {
//余额不足时,线程进入等待队列,自动释放锁,让位给其它线程给账户存款
//需要其它线程唤醒
sufficientFunds.await();
};
System.out.println(Thread.currentThread());
//扣款
accounts[from] -= amount;
System.out.printf("%10.2f 从账户 %d 到账户 %d",amount,from,to);
//存款
accounts[to] += amount;
System.out.printf(" 总金额: %10.2f%n",getTotalBalance());
//唤醒所有等待队列中满足sufficientFunds条件的线程,进入可运行状态
sufficientFunds.signalAll();
}finally{
bankLock.unlock();
}
}
//计算银行总金额
public double getTotalBalance(){
bankLock.lock();
try{
double sum = 0;
for (double a :accounts){
sum += a;
}
return sum;
}finally{
bankLock.unlock();
}
}
//获得银行账户数量
public int size(){
return accounts.length;
}
}
代码解释:
条件对象中条件的定义处在如下形式:
while(!(ok to proceed))
condition.await();
为什么用while而不用if?
因为处在等待状态的线程可能被虚假唤醒,然后所有满足该条件的线程重新竞争锁,并且需要被分配到时间片执行,没有竞争到的线程需要重新回到等待状态。而如果使用if,所有线程判断一次条件就都运行下去了,而在此过程条件可能被其它线程所改变,出现未知错误。
何时唤醒等待队列的线程?
当对象的状态有利于等待线程的方向改变时调用signalAll方法。例如,转账完成时,余额不足的账户可能余额变成充足,此时可以检查条件。
signalAll只是解除等待线程的阻塞,线程依然需要竞争对对象资源的访问。
signal是随机解除等待队列中某个线程的阻塞状态。
2.synchronized关键字
Lock和Condition接口提供了高度的锁定控制,然而大多数并不需要那样的控制。java中每一个对象都有一个内部锁和一个内部条件,如果方法用synchronized关键字声明,那么对象的锁保护整个方法。
public synchronized void method()
{
method body
}
//等价于
public void method()
{
this.intrinsicLock.lock();
try
{
method body
}
finally{this.intrinsicLock.unlock();}
}
有了synchronized关键字之后,所以Bank类的transfer方法可以简单的声明为synchronized,而不是使用一个显式的锁。
public class Bank
{
private double[] accounts;
public synchronized void transfer(int from, int to, int amount) throws InterruptedException
{
while(accounts[from] < amount)
wait();//使线程进入等待状态直到被通知,该方法只能在同步方法中调用
accounts[from] -= amount;
accounts[to] += amount;
notifyAll();//解除该对象上调用wait方法的线程的阻塞状态,该方法法只能在同步方法中调用
}
//省略其它代码
}
为方便的调用其它对象的内部锁,synchronized还有如下形式的用法
synchronized (obj)
{
//临界区
}
根据每一个对象有一个内部锁,可以创建一个同步阻塞来控制线程间的互斥与并发。
public class SeparateGroups
{
private double aVal = 0.0;
private double bVal = 1.1;
protected Object lockA = new Object();//创建对象构造同步阻塞
protected Object lockB = new Object();//创建另一个对象构造同步阻塞
//aVal的读方法和写方法互斥,同步阻塞
public double getA(){
synchronized(lockA){
return aVal;
}
}
public double setA(double val){
synchronized(lockA){
aVal = val;
}
}
//bVal的读方法和写方法互斥,同步阻塞
public double getB(){
synchronized(lockB){
return bVal;
}
}
public double setB(double val){
synchronized(lockB){
bVal = val;
}
}
//但是aVal和bVal可以并发运行,因为它们使用不同对象的锁,在不同的同步阻塞中
//获得两个锁,进行同步设置
public void reset(){
synchronized(lockA){
synchronized(lockB){
aVal = bVal =0.0;
}
}
}
}
synchronized的使用技巧:
synchronized语句可以定义比方法小的同步代码,同步语句应旨在完全必要的时候才使用锁。比如,进行复杂计算并将结果赋给某个域的方法,应该只保护实际的域赋值,而不是计算过程
3.volatile域
有时仅仅为了读写一两个实例域就使用同步,未免开销太大。volatile是一种轻量级的同步方式。
目前普通的实例域面临的问题:
- 使用现代的多处理器计算机,会将内存中的值缓存到寄存器以加快程序运行,而此过程中内存中的值可能发生改变,导致多个CPU寄存器取到了不同的值。
- 而编译器会对代码进行重排序,以达到更大的吞吐量。而此过程中,实例域的值对其它线程不可见。
使用volatile保护域之后,可以让cpu每次在使用域之前,再次在内存中获得值,而不是从寄存器缓存中,也禁止编译器对该域进行重排序。
private boolean done;
public synchronized boolean isDone(){return done;}
public synchronized void setDone(){done = true;}
//等价于
private volatile boolean done;
public boolean isDone(){return done;}
public void setDone(){done = true;}
volatile不保证原子性,仅在满足以下条件的场景中使用:
- 运算结果并不依赖变量的当前值,或者能够只有单一线程能修改变量的值
- 变量不需要与其它的状态变量共同参与不变约束。
//代码示例,以下代码修改实例域的值,并不依赖之前的值,所以可以用volatile
private volatile boolean shutdownTag;
public void shutdown(){
shutdownTag = true;
}
public void doWork(){
while(!shutdownTag){
//工作代码
}
}
//代码示例,以下代码依赖实例域之前的值,所以不适合用volatile,应该用synchronized上锁,尽管开销大一些
private volatile boolean shutdownTag;
private volatile int sum = 0;
public void reverse(){
shutdownTag = !shutdownTag;
}
public void increase(){
sum++;
}
4.final关键字
被final修饰的字段一旦在构造器初始化完成,并且构造器没有将“this“引用传递出去,那在其它线程就能看见final字段的值。
5.原子性