Java多线程安全问题及解决方案
一、问题引入
通过最常见的多窗口售票问题引入线程安全的问题。代码如下:
注:这里使用Runnable接口来实现线程,这样做是为了共享代售票这个资源,如果我们使用继承Thread来操作,需要将代售票ticketCount设置为全局的公共变量才能实现效果。
package com.example.thread.one;
public class ThreadDemo {
public static void main(String[] args) {
SellTicketRunnable runnable = new SellTicketRunnable();
//线程1.模拟窗口1
Thread thread1 = new Thread(runnable);
//线程2.模拟窗口2
Thread thread2 = new Thread(runnable);
thread1.setName("窗口1");
thread2.setName("窗口2");
//开启线程
thread1.start();
thread2.start();
}
}
class SellTicketRunnable implements Runnable{
//公共资源。代售票
private int ticketCount = 100;
@Override
public void run() {
while (true) {
if(ticketCount > 0){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}//模拟售票过程
System.out.println(Thread.currentThread().getName()
+ "正在卖第:" + ticketCount-- + "张票");
}else{
//终止线程
Thread.currentThread().interrupt();
}
}
}
}
打印输出结果如下:
会发现两个窗口都在卖同一张票,这就是由于多线程导致的线程安全问题。
二、分析问题带来的原因
判断程序是否有线程的依据如下:
1、是否有多线程环境。程序中是否使用了多线程。
2、是否有共享数据。
3、是否有多条语句操作共享数据。
上面的例子中共享资源就是票,多个线程同时对变量ticketCount进行了操作导致了问题。
三、解决方案
通常我们使用同步(关键字为synchronized)来解决这种由于多线程同时操作共享数据带来的线程安全问题。
同步可以理解为:我们将多条操作共享数据的语句代码包成一个整体,让某个线程执行时其他线程不能执行。
同步方案包括三种方式,它们对应的锁对象是不一样的。另外我们可以通过加锁来同步代码块,解决安全问题。
因此常用的解决方案有四种。
注意:
同步可以解决问题的根本原因就在于锁对象上,因此要避免线程安全问题,多个线程必须使用同一个锁对象,否则,不能解决问题。
1、同步代码块
格式:synchronized(对象) {
需要被同步的代码;
}
这里的锁对象可以是任意对象。
利用这种方法优化后的代码如下:
package com.example.thread.one;
public class ThreadDemo {
public static void main(String[] args) {
SellTicketRunnable runnable = new SellTicketRunnable();
//线程1.模拟窗口1
Thread thread1 = new Thread(runnable);
//线程2.模拟窗口2
Thread thread2 = new Thread(runnable);
thread1.setName("窗口1");
thread2.setName("窗口2");
//开启线程
thread1.start();
thread2.start();
}
}
class SellTicketRunnable implements Runnable{
//公共资源。代售票
private int ticketCount = 100;
//创建锁对象。这个也是共享的资源。多个线程使用的是同一锁对象
private Object obj = new Object();
@Override
public void run() {
while (true) {
synchronized (obj) {
if(ticketCount > 0){
try {//模拟售票过程
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+ "正在卖第:" + ticketCount-- + "张票");
}
}
}
}
}
2、同步方法
格式:把同步(synchronized)加在方法上。这时的锁对象是this。
利用这种方法优化的代码如下:
package com.example.thread.one;
public class ThreadDemo {
public static void main(String[] args) {
SellTicketRunnable runnable = new SellTicketRunnable();
// 线程1.模拟窗口1
Thread thread1 = new Thread(runnable);
// 线程2.模拟窗口2
Thread thread2 = new Thread(runnable);
thread1.setName("窗口1");
thread2.setName("窗口2");
// 开启线程
thread1.start();
thread2.start();
}
}
class SellTicketRunnable implements Runnable {
// 公共资源。代售票
private int ticketCount = 100;
@Override
public void run() {
while (true) {
sellTicket();
}
}
//同步方法.这时的锁对象是this。也就是SellTicketRunnable
private synchronized void sellTicket() {
if (ticketCount > 0) {
try {
// 模拟售票过程
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "正在卖第:"
+ ticketCount-- + "张票");
}
}
}
下边的代码会证明同步方法的锁对象是this。如将同步代码块中的锁对象更换就会出现线程安全问题。
代码如下:
package com.example.thread.one;
public class ThreadDemo {
public static void main(String[] args) {
SellTicketRunnable runnable = new SellTicketRunnable();
// 线程1.模拟窗口1
Thread thread1 = new Thread(runnable);
// 线程2.模拟窗口2
Thread thread2 = new Thread(runnable);
thread1.setName("窗口1");
thread2.setName("窗口2");
// 开启线程
thread1.start();
thread2.start();
}
}
class SellTicketRunnable implements Runnable {
// 公共资源。代售票
private static int ticketCount = 100;
private int x = 0;
@Override
public void run() {
while (true) {
if(x % 2 == 0){
//同步代码块实现同步.这里设置的锁对象是该类的字节码文件对象。
synchronized (this) {
if (ticketCount > 0) {
try {
// 模拟售票过程
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "正在卖第:"
+ ticketCount-- + "张票");
}
}
}else{
sellTicket();
}
x++;
}
}
// 同步方法.这时的锁对象是this。和上面的同步代码块的锁对象相同,这样才能解决线程安全问题。
private synchronized void sellTicket() {
if (ticketCount > 0) {
try {
// 模拟售票过程
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "正在卖第:"
+ ticketCount-- + "张票");
}
}
}
3、静态同步方法
格式:将同步加在静态方法上。此时的锁对象为当前类的字节码文件对象。
为了更好的理解此时的锁对象,通过下边的代码来直观的理解只有同一锁对象时才能解决线程安全问题:
package com.example.thread.one;
public class ThreadDemo {
public static void main(String[] args) {
SellTicketRunnable runnable = new SellTicketRunnable();
// 线程1.模拟窗口1
Thread thread1 = new Thread(runnable);
// 线程2.模拟窗口2
Thread thread2 = new Thread(runnable);
thread1.setName("窗口1");
thread2.setName("窗口2");
// 开启线程
thread1.start();
thread2.start();
}
}
class SellTicketRunnable implements Runnable {
// 公共资源。代售票
private static int ticketCount = 100;
private int x = 0;
@Override
public void run() {
while (true) {
if(x % 2 == 0){
//同步代码块实现同步.这里设置的锁对象是该类的字节码文件对象。
synchronized (SellTicketRunnable.class) {
if (ticketCount > 0) {
try {
// 模拟售票过程
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "正在卖第:"
+ ticketCount-- + "张票");
}
}
}else{
sellTicket();
}
x++;
}
}
// 同步方法.这时的锁对象是该类的字节码文件对象。和上面的同步代码块的锁对象相同,这样才能解决线程安全问题。
private static synchronized void sellTicket() {
if (ticketCount > 0) {
try {
// 模拟售票过程
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "正在卖第:"
+ ticketCount-- + "张票");
}
}
}
4、加锁Lock解决问题
将需要一次执行完代码块用lock()和unlock()包裹,来保证线程安全。
代码如下:
package com.example.thread.one;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadDemo {
public static void main(String[] args) {
SellTicketRunnable runnable = new SellTicketRunnable();
// 线程1.模拟窗口1
Thread thread1 = new Thread(runnable);
// 线程2.模拟窗口2
Thread thread2 = new Thread(runnable);
thread1.setName("窗口1");
thread2.setName("窗口2");
// 开启线程
thread1.start();
thread2.start();
}
}
class SellTicketRunnable implements Runnable {
// 公共资源。代售票
private int ticketCount = 100;
//定义锁对象。ReentrantLock是Lock的实现类.
private Lock lock = new ReentrantLock();
@Override
public void run() {
while (true) {
try {
//加锁
lock.lock();
if (ticketCount > 0) {
try {
// 模拟售票过程
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()
+ "正在卖第:" + ticketCount-- + "张票");
}
} finally{
//释放锁
lock.unlock();
}
}
}
}
四、Java中线程安全的类
Vector与ArrayList
Vector属于线程安全级别的,但是大多数情况下不使用Vector,因为线程安全需要更大的系统开销
HashTable与HashMap
Hashtable 中的方法是同步的,而HashMap中的方法在缺省情况下是非同步的。在多线程并发的环境下,可以直接使用Hashtable,但是要使用HashMap的话就要自己增加同步处理了。hashtable是线程安全的,即hashtable的方法都提供了同步机制;hashmap不是线程安全的,即不提供同步机制 ;hashtable不允许插入空值,hashmap允许!
StringBuilder与StringBuffer
StringBuilder和StringBuffer的方法是一模一样,StringBuffer线程安全,StringBuilder线程不安全,但性能更高
Stack 继承自Vector 也是线程安全的。
可以由java.util.Collections来创建线程安全的集合,如:Connections.synchronizedSet(Set); Connections.synchronizedList(List);Connections.synchronizedMap(Map)等,其简单的原理是每个方法都增加了synchronized来保证线程安全。
注:StringBuffer是线程安全,而StringBuilder是线程不安全的。对于安全与不安全没有深入的理解情况下,易造成这样的错觉,如果对于StringBuffer的操作均是线程安全的,然而,JAVA给你的保证的线程安全,是说它的方法是执行是排它的,而不是对这个对象本身的多次调用情况下,还是安全的。看看下边的例子,在StringBufferTest中有一个数据成员contents它是用来扩展的,它的每一次append是线程安全的,但众多次append的组合并不是线程安全的,这个输出结果不是太可控的,但如果对于log和getContest方法加关键字synchronized,那么结果就会变得非常条理,如果换成StringBuider甚至是append到一半,它也会让位于其它在此基础上操作的线程:
public class StringBufferTest {
private StringBuffer contents = new StringBuffer();
public void log(String message){
contents.append(System.currentTimeMillis());
contents.append("; ");
contents.append(Thread.currentThread().getName());
for(int i=0;i<10000;i++){
contents.append(i);
contents.append(message); //append本身是线程安全的,修改contents时,其它线程无法访问。
contents.append("\n");
}
contents.append("\n\n");
}
public void getContents(){
System.out.println(contents);
}
}
class RunThread extends Thread{
String message;
StringBufferTest buffer;
public RunThread(StringBufferTest buffer, String message){
this.buffer = buffer;
this.message = message;
}
public void run(){
while(true){
buffer.log(message);
buffer.getContents();
}
}
public static void main(String[] args) {
StringBufferTest ss = new StringBufferTest();
new RunThread(ss, "you").start();
new RunThread(ss, "me").start();
new RunThread(ss, "she").start();
}
}
StringBuilder和StringBuffer的方法是一模一样,就是一个多线程和一个单线程的问题。线程调用同一StringBuffer 的append方法,这跟他是不是线程安全没有关系的,除非你的结果是append的一系列字符串变乱了,那才能说明他是线程不安全的。线程安全是指任何时刻都只有一个线程访问临界资源。线程安全 并不是说他的一系列操作是同步的 只是对于他执行某个方法的时候不允许别的线程去改变。针对一个类来说是不是线程安全就要看,多个线程在同时在运行,这些线程可能会同时执行某个方法。但是每次运行结果和单线程执行的结果一样,那么就可以说是线程安全的。因为log方法没有上锁,每个现在在append锁释放后,都可能得到cpu的执行片段。
但不要对多线程安全存在误解:
public String toString(){
StringBuffer buffer = new StringBuffer();
buffer.append('<');
buffer.append(this.name);
buffer.append('>');
return buffer.toString();
}
这个代码是完全线程安全的,在方法内部定义的变量,在每个线程线程进入的时候都会创建这个局部变量!不涉及线程安全问题。通常涉及系统安全的变量一般都是成员变量! stringBuffer本身的内部实现是现场安全的!线程安全那是类本身提供的功能是安全的。即你提供插入一个字符串,那么这个字符串插入是安全的,但是要插入两个字符串,两个的顺序你来定,这之间如果有别的插入出错就不管类的事情了,是你自己代码的问题。