线程安全与线程同步
文章目录
前言
前面已经学习了线程的使用,在使用线程的时候还要注意线程安全。
一、什么是线程安全
假设这样一种场景,如果一个客户在银行有一个账户,客户可以通过柜台,手机app,自助取款机进行存取款操作。如果某一天客户同一时间点在手机APP和自助取款机进行取钱操作,这个时候就必须有一个机制保证客户的余额不会乱,不会因为用不同的方式同时对余额进行操作而使余额不准确。
同样的,前面在多线程的概念中已经说明多线程同一类下的多个线程可以共享进程的堆和方法区资源,既多个线程共享类变量。
创建一个AccountThread线程类,用来创建一个线程给用户进行存取钱操作。
代码如下(示例):
创建AccountClass类
public class AccountClass {
public int accountBalance=10000;
}
public class AccountThread extends Thread{
private final AccountClass AccountBalance;
private final String method;
private Object lock;
public AccountThread(AccountClass balance,String name){
this.AccountBalance=balance;
this.method=name;
}
@Override
public void run() {
while(AccountBalance.accountBalance > 0) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// synchronized (AccountBalance){
//原子操作
AccountBalance.accountBalance-=1000;
System.out.println("用户通过:"+method+"取走了1000, " + "余额为:"+AccountBalance.accountBalance);
}
// }
}
}
主函数为ManageAccount,创建三个对应不同方式的线程类,用来操作账户余额,在主函数中有一个共有变量就是用户的账户余额AccountBalance。
代码如下(示例):
public class ManageAccount {
public static void main(String[] args) {
AccountClass AccountBalance=new AccountClass();
new AccountThread(AccountBalance,"APP").start();
new AccountThread(AccountBalance,"自助取款机").start();
new AccountThread(AccountBalance,"柜台办理").start();
}
}
结果为:
如果我们创建了了多个线程,它们都可以访问主函数中的AccountBalance变量并且共用的是这个变量。执行哪个线程是由CPU决定的,如果我们不加以限制,可能会执行某一个线程的过程中暂停这个线程去执行其他线程。
假设用户现在余额是5000,可能会出现APP这个线程获取了AccountBalance后,但是没有执行减1000的操作时CPU又去执行自助取款机这个操作,AccountBalance又被自助取款机这个线程获取到了,此时他们两个线程获取到的余额都为5000,然后再去执行减1000并更新操作,会导致AccountBalance变量信息不准确。
所以说这种多个线程可以读写同一个共享变量,由于信息更新不及时导致的错误叫做线程安全。
总结:多个线程可以共享一个进程的变量时,如果线程需要对这个变量进行修改操作,则可能会因为数据更新不及时导致变量信息不准确而引发线程不安全。如果线程对这个变量只有读操作,没有更新操作则这个线程没有线程安全问题。
二、线程同步
为了解决线程安全问题就要引出线程同步这个概念。
线程同步与原子操作
线程同步
为了解决线程安全引入线程同步概念:多个线程共用一个变量时必须保证这个变量不会因为cpu分配不同线程导致变量出现安全问题。
原子操作:
可以理解为在原子操作中的语句一旦开始执行,就必须执行到原子操作结束,某一线程中的原子操作不会出现执行到中间某一条语句被cpu分配给其他线程。
例:在银行存取款操作中,AccountBalance-=1000这句代码,实际上是将AccountBalance这个变量在它的物理地址中取出来,然后进行减1000操作,最后在更新到原物理地址上去,如果不采用原子操作,则会出现某一个线程取出来了这个变量,减去了1000,但是没有执行更新操作,cpu又去执行去它线程导致线程安全问题。如果采用了原子操作,取出变量,操作变量,更新变量看做一个原子操作,一旦取出就必须等到它更新以后才可以执行其他线程。
volatile关键字
volatile关键字用来声明某一个变量,被声明的变量会具有可见性,简单的理解就是多个线程共用一个变量时,如果这个变量被某一个线程修改过了,他会立刻更新这个变量,其他线程在调用这个变量时会取到更新过的变量。
volatile关键字的另一个作用是确保有序性,假设在初始化变量时,有int a=1,和int b=100两个变量需要初始化,代码中写的是
int a=1;
int b=100;
在初始化的时候计算机可能会先执行int a=1,也可能会先执行int b=100
因为这两个变量初始化的时候没有关系,所以计算机可能不按顺序初始化,如果多个线程共享这两个变量,并用a和b作为判断条件,如果出现了先初始化了b变量然后cpu切换了线程,新的线程可能会误认为a也初始化了会导致程序出错。
这时就需要用volatile来保证有序性,如果一个变量用volayile修饰了,则他前面的代码一定比他先执行,后面的代码一定比他后执行,这样可以确保在多线程过程中,声明变量时不会出现使用没声明的变量的情况。
保证线程安全的操作
1. synchronized
synchronized的作用是被这个修饰符修饰的语句或方法会被看做使用了原子操作,既一旦进入就必须执行完才可以去其它线程。
用上面的银行存取款说明,在语句中使用synchronized可以确保原子性避免线程安全问题。
用法:
synchronized(){}
括号里面应该指向人任意一个对象,注意多线程时应指向同一个对象,所以最好用一个地址不会变的对象来确保准确性。
代码如下:
while (true) {
synchronized (AccountBalance) {
//原子操作
if(AccountBalance.accountBalance>0){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
AccountBalance.accountBalance -= 100;
System.out.println("用户通过:" + method + "取走了100, " + "余额为:" + AccountBalance.accountBalance);
}
}}
将线程中的减操作放在synchronized (AccountBalance) {}代码块中这样
synchronized中的语句就是一个原子操作。(这里为了观察方便将1000改为了100)。
可以看到结果如下:
这样就可以避免线程安全问题。可以理解为synchronized(){}模块括号中的对象为锁对象,一旦一个线程拿到了这个锁就必须执行完里面的代码并归还锁下一个线程才能够执行里面的代码。如果一个线程抢到了执行权发现锁对象被另一个线程使用着就会进入wait状态等待其他线程的原子操作结束。
2. synchronized方法
和上面的方法类似,将原子操作写成一个方法,然后通过重写run方法调用。与implements Runnable配合使用。
代码如下:
public class AccountThread implements Runnable {
private final AccountClass AccountBalance;
public AccountThread(AccountClass balance) {
this.AccountBalance = balance;
}
@Override
public void run() {
while (true) {
sy();
}
}
public synchronized void sy() {
if (AccountBalance.accountBalance > 0) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
AccountBalance.accountBalance -= 100;
System.out.println("用户通过:" + Thread.currentThread().getName() + "取走了100, " + "余额为:" + AccountBalance.accountBalance);
}
}
}
public class ManageAccount {
public static void main(String[] args) {
//创建线程任务对象
AccountClass accountBalance=new AccountClass();
AccountThread accountThread = new AccountThread(accountBalance);
//创建三个窗口对象
Thread t1 = new Thread(accountThread, "APP");
Thread t2 = new Thread(accountThread, "自助取款机");
Thread t3 = new Thread(accountThread, "柜台办理");
t1.start();
t2.start();
t3.start();
}
}
3. 手动使用Lock
还可以通过Lock对象来实现手动上锁解锁操作,手动上锁解锁和synchronized效果类似。
注意因为手动上锁可能会出现上锁以后的代码出现问题导致执行不到解锁的语句,此时就不能调用其它线程,所以将解锁语句用finally修饰,不管有没有错误都会执行解锁。
代码如下:
其余部分一样,只是AccountThread这个类里面创建一个Lock对象,手动用lock.lock()和 lock.unlock();去上锁解锁。
private Lock lock = new ReentrantLock();
@Override
public void run() {
while (true) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
lock.lock();
if (AccountBalance.accountBalance > 0) {
AccountBalance.accountBalance -= 100;
System.out.println("用户通过:" + Thread.currentThread().getName() + "取走了100, " + "余额为:" + AccountBalance.accountBalance);
}
} finally {
lock.unlock();
}
}
}
结果无误:
总结
在使用多线程的时候要注意多个线程对一个共享变量都可以进行修改更新的时候带来的线程安全问题,可以通过synchronized模块,synchronized方法和手动lock的方法进行线程同步。