首先确定好,线程安全跟前文学到的内容一致,即多个线程访问同一共享资源而又不加限制,则称为线程不安全。
注意:多个线程访问共享资源会出现线程安全问题,而共享资源在类中的体现即为对象的成员变量。所以实际考虑中,是多个线程访问同一实例对象产生的情况,因为假使不同的线程访问不同的对象,那么相当于一个线程拥有一份自己独有的对象,对象内的成员变量也是线程独自持有的,那么不涉及到访问共享资源的问题。而实际中,也是多个线程要访问同一实例对象,如String中的bean对象都是单例模式,那么需要用到此对象时,就相当于多个线程共享一份对象,此时需要考虑线程安全问题。
成员变量和静态变量是否线程安全?
- 如果它们没有共享,则线程安全
如果它们被共享了,根据它们的状态是否能够改变,又分为两种情况
-如果只有读操作,则线程安全<br /> -如果有读写操作,则这段代码是临界区,需要考虑线程安全
详细解释上述两种变量,首先类的静态变量,是类持有的,类中任何对象以及类本身可以直接访问该静态对象,所以多个线程中的类对象可以直接访问,则会存在线程不安全的情况,比如:
public class Test8Blocks {public static void main(String[] args) {Number n1 =new Number();Number n2 = new Number();new Thread(()->{Number.length=3;}).start();new Thread(()->{Number.length=4;}).start();}}class Number{public static int length = 1;}
即两个线程同时访问类的共享静态变量,则造成线程不安全。
其次是类的成员变量,成员变量也可以在多个线程中共享访问,比如只有访问同一对象实例的成员变量,那么这块变量也是线程共享的,则会出现线程不安全的问题,如下:
public class Test8Blocks {public static void main(String[] args) {Number number = new Number();new Thread(()->{number.setLength(2);}).start();new Thread(()->{number.setLength(3);}).start();}}class Number{public int length;public int getLength() {return length;}public void setLength(int length) {this.length = length;}}
可以看到多个线程仍访问一块共享资源,即一个类对象的一个成员变量,故可能会引发线程不安全问题。
局部变量是否线程安全?
1. 普通局部变量
public static void test1(){int i=10;i++;}
如果多个线程调用test1方法不会有线程安全问题,因为每个线程会有自己的栈区,且栈帧对应调用的方法,所以局部变量i有多份,每份存在所属线程的栈帧中,所以并不会被多个线程共享。
总结:一般的局部变量都不会有线程安全问题
2. 引用局部变量(在方法内部不将变量引用暴露出去)
import java.util.ArrayList;public class LocatedVia {static final int THREAD_NUMBER = 2;static final int LOOP_NUMBER = 200;public static void main(String[] args) {ThreadSafe test = new ThreadSafe();for(int i=0;i<THREAD_NUMBER;i++){new Thread(()->{test.method1(LOOP_NUMBER);},"Thread"+(i+1)).start();}}}class ThreadSafe{public final void method1(int loopNumber){ArrayList<String> list = new ArrayList<>();for(int i=0;i<loopNumber;i++){method2(list);method3(list);}}private void method2(ArrayList<String> list){list.add("1");}private void method3(ArrayList<String> list){list.remove(0);}}
本例中,两个线程调用方法时,list成为了方法method1内部的局部变量,所以每个线程都会在堆中创建一个list实例,此种情况没有将list引用暴露出去,没有逃离方法的作用访问。
示意图如下:
3. 引用局部变量(在方法内部将变量引用暴露出去)
import java.util.ArrayList;public class LocatedVia {static final int THREAD_NUMBER = 2;static final int LOOP_NUMBER = 200;public static void main(String[] args) {ThreadSafeSubClass test = new ThreadSafeSubClass();for(int i=0;i<THREAD_NUMBER;i++){new Thread(()->{test.method1(LOOP_NUMBER);},"Thread"+(i+1)).start();}}}class ThreadSafe{public final void method1(int loopNumber){ArrayList<String> list = new ArrayList<>();for(int i=0;i<loopNumber;i++){method2(list);method3(list);}}public void method2(ArrayList<String> list){list.add("1");}public void method3(ArrayList<String> list){list.remove(0);}}class ThreadSafeSubClass extends ThreadSafe{@Overridepublic void method3(ArrayList<String> list){new Thread(()->{list.remove(0);});}}
此例中,当某一线程调用method3方法时,由于ThreadSafeSubClass类中的method3方法重写了父类的方法,所以实际调用的是子类中的method3方法,而该方法将引用变量list暴露给了新创建的线程,相当于其他线程中也访问了list对象,出现线程安全问题。
注意本例中父类的method2和method3方法均是public,故可以被子类继承。如果父类中的方法设为private,则不可被子类重写(父类私有不可被子类继承和重写),一定程度上可以解决线程安全问题。
也可以将方法定义为final,防止子类影响本方法。
4. 成员变量
import java.util.ArrayList;public class LocatedVia {static final int THREAD_NUMBER = 2;static final int LOOP_NUMBER = 200;public static void main(String[] args) {ThreadUnsafe test = new ThreadUnsafe();for(int i=0;i<THREAD_NUMBER;i++){new Thread(()->{test.method1(LOOP_NUMBER);},"Thread"+(i+1)).start();}}}class ThreadUnsafe{ArrayList<String> list = new ArrayList<>();public void method1(int loopNumber){for (int i=0;i<loopNumber;i++){method2();method3();}}private void method2(){list.add("1");}private void method3(){list.remove(0);}}
本例中,主方法中创建两个线程,每个线程中调用ThreadUnsafe类中的method2方法和method3方法;
但注意两个线程使用的是同一个对象,所以本质上两个线程访问的是同一个对象的成员变量list,存在共享情况,会出现线程安全问题。
示意图如下:
二、常见线程安全类及其组合
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent包下的类
线程安全类是指,多个线程调用同一个实例(一定是同一个实例,很重要!)的某个方法时,是线程安全的,可理解为类的每个方法都是原子操作。调用的时候不用上锁,实际查看源码时,每个方法均声明成synchronized,synchronized目的即为保证操作的原子性。
但注意,每个方法是原子操作,但方法组合不是原子操作,依然会出现线程安全问题,如:
Hashtable table = new Hashtable();//线程1 线程2if(table.get("key")==null){table.put("key",value);}
尽管get与put方法均是原子方法,但由于线程上下文切换,还是会造成线程安全问题。比如线程1刚执行到get方法,切换到线程2执行put方法,线程1回来再执行put方法,则值不对,相当于put两遍。
题外话:这里学的基础背景是,不同线程访问共享资源,而访问共享资源的形式是通过类的方法访问,所以判断是否线程安全,要看所调用的方法内部是否对共享资源执行了原子操作。
这里不同线程调用的是同一个实例对象,因为调用一个实例对象才涉及到去更改该对象的成员变量等属性。否则如果不是同一个实例对象,那么多个线程互不干扰。
三、不可变类线程安全性
String、Interger都是不可变类,其内部状态不可改变,因此它们的方法都是线程安全的。
这句话怎么理解,如果是不可变类,拿String类为例,则相当于字符串不可变,那么即使多个线程访问同一个实例对象(共享资源)中的方法时,也不会出现线程安全问题,因为“读写”操作才会造成线程安全问题,只读不写不会产生问题。
四、线程安全实例分析
例1:
class MyServlet extends HttpServlet{//是否安全?Map<String,Object> map = new HashMap<>();//是否安全?String s1="...";//是否安全?final String s2="...";//是否安全?Data D1=new Data();//是否安全?final Data D2=new Data();public void deGet(){//使用上述变量}}
Tomcat中Servlet对象只存在一份,所以Servlet对象会被多线程所共享,故会产生线程安全问题,而仅有一个对象,那么对象的成员变量也均会被共享,所以成员变量可能会造成线程安全问题。
本例中,Map容器会被多个线程所修改,所以是不安全的,s1与s2均是字符串对象,是不可变类对象,故线程安全,D1和D2均线程不安全,因为D1对象内部一些成员变量可被修改,即使D2由final修饰,也只是保证引用D2不变(即依旧指向同一块堆内存),但D2对象的成员变量仍然可变。
例2:
class MyServlet extends HttpServlet{//是否安全?private UserService userService = new UserServiceImpl();public void doGet(){userService.update();}}class UserServiceImpl implements UserService{private int count=0;public void update(){count++;}}
由于MyServlet对象是单例,所以成员变量userService也是单例,故多个线程共享userService对象,在userService这一步,即使多个线程共享userService对象也是线程安全的,但userService对象有成员变量count,可能调用update方法对对象的成员count值做改变,所以是线程不安全的。
例3:
@Aspect@Componentpublic class MyAspect {//是否安全?private long start =0L;@Before("execution(* *(..))");public void before(){start = System.nanoTime();}@After("execution(* *(..))");public void after(){long end = System.nanoTime();System.out.println("cost time:"+(end-start));}}
本例中的应用场景是Spring框架中的AOP,且Spring中被@Component修饰的均为SpringBean对象,所以是单例模式,那么多个线程要共享这一份对象,与例2一样,start会被多个线程修改,所以线程不安全。
例4:
public class MyServlet extends HttpServlet {//是否安全private UserService userService = new UserServiceImpl();public void doGet(){userService.update;}}class UserServiceImpl{//是否安全private UserDao userDao = new UserDaoImpl();public void update(){userDao.update();}}class UserDaoImpl implements UserDao{public void update(){String sql="update column set";try(Connection conn= DriverManager.getConnection("","","")){}catch (Exception e){}}}
从上往下分析,MyServlet单例,成员变量userService对象,UserServiceImpl类的成员变量userDao也被共享,再向下,UserDaoImpl类中没有成员变量,所以即使多个线程共享同一实例对象,也没有可以去并发更改的值,所以整体线程安全。
一般来说,没有成员变量,或者将成员变量改成局部变量,线程就是安全的。
例5:
public class MyServlet extends HttpServlet {//是否安全private UserService userService = new UserServiceImpl();public void doGet(){userService.update;}}class UserServiceImpl{//是否安全private UserDao userDao = new UserDaoImpl();public void update(){userDao.update();}}class UserDaoImpl implements UserDao{private Connection conn = null;public void update() throws SQLException{String sql="update user set password =? where username =?";conn = DriverManager.getConnection("","","");// ...conn.close();}}
conn变成成员变量而不是局部变量,则多个线程调用时会出现问题,比如线程1调用对象的update方法后conn还没来得及写,线程2则将conn对象关闭,再回到线程1时候则会报错,conn为空。
例6(本例需要细品):
public class MyServlet extends HttpServlet {//是否安全private UserService userService = new UserServiceImpl();public void doGet(){userService.update;}}class UserServiceImpl{public void update(){//是否安全UserDao userDao = new UserDaoImpl();userDao.update();}}class UserDaoImpl implements UserDao{private Connection conn = null;public void update() throws SQLException{String sql="update user set password =? where username =?";conn = DriverManager.getConnection("","","");// ...conn.close();}}
此例中,调用update方法时,每个线程会创建一个userDao对象,所以在这行代码之后,每个线程都拥有一个独有的UserDaoImpl类对象,不涉及到多个线程并发更改同一个UserDaoImpl类对象的成员变量conn的情况,所以本例线程安全。
例7(暴露引用):
public abstract class Test {public void bar(){//是否安全SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");foo(sdf);}public abstract foo(SimpleDateFormat sdf);public static void main(String[] args) {new Test().bar();}}
其中foo的行为是不确定的,可能导致不安全的发生,被称之为外星方法。
public void foo(SimpleDateFormat sdf){String dataStr = "1999-10-11 00:00:00";for(int i=0;i<20;i++){new Thread(()->{try {sdf.parse(dataStr);}catch (ParseException e){e.printStackTrace();}})}}
父类中,不知道子类如何实现foo方法,可以将sdf对象的引用暴露出去,即暴露给别的线程,那么相当于多个线程共享一个SimpleDataFormat类对象,而SimpleDataFormoat类对象内部的成员变量是可访问更改的,所以线程不安全。
再啰嗦一遍:线程安全问题是指多个线程共用一个实例对象,进而可能出现访问共享资源(对象的成员变量或类变量)指令交错的问题。
五、习题
1.卖票
本例中,多个线程的共享变量是window对象以及amoutList对象,所以访问这两个对象成员变量的代码可视为临界区,需要使用synchronized保护,临界区的定义为:对共享资源作读写操作的那段代码。
所以更改后的代码为:在window.sell()方法上加上synchronized,在add()上不需要加入synchronized,因为add方法本身就是线程安全的。
注意,是否需要考虑组合安全问题?
int amount = window.sell(random(5));//统计卖票数amountList.add(thread);
不需要考虑组合安全,因为是两个对象的读写操作,不涉及到一个对象的语句组合问题。
如果按以下代码,可能会出现线程安全问题。
Hashtable table = new Hashtable();//线程1 线程2if(table.get("key")==null){table.put("key",value);}
综上,本例总结,看线程是否安全,首先看线程共享的对象/变量.
(1)访问对象的成员变量等是否存在读写操作,读写那段代码成为临界区,需要保护。
(2)组合代码是否涉及线程安全
2.转账
import java.util.Random;public class ExerciseTransfer {public static void main(String[] args) {Account a = new Account(1000);Account b = new Account(1000);Thread t1 = new Thread(()->{for (int i=0;i<1000;i++){a.transfer(b,randomAmounnt());}},"t1");Thread t2 = new Thread(()->{for (int i=0;i<1000;i++){b.transfer(a,randomAmount());}},"t2");t1.start();t2.start();t1.join();t2.join();System.out.println("total:{}",(a.getMoney()+b.getMoney()));}}class Account{private int money;public Account(int money){this.money=money;}public int getMoney(){return money;};public void setMoney(int money){this.money=money;}//可知这段代码即为,临界区public void transfer(Account target,int amount){if(this.money>=amount){this.setMoney(this.getMoney()-amount);target.setMoney(target.getMoney()+amount);}}}
可以看出线程1与线程2访问的共享对象为a对象和b对象,共享资源为a.money与b.money,为了保证线程安全,要对临界区保护,保护的目标是使得临界区的代码由线程串行执行,所以要对临界区方法上锁,这时要考虑对象上锁还是类上锁。
Thread t1 = new Thread(()->{for (int i=0;i<1000;i++){a.transfer(b,randomAmounnt());}},"t1");Thread t2 = new Thread(()->{for (int i=0;i<1000;i++){b.transfer(a,randomAmount());}},"t2");
a.transfer()与b.transfer(),明显只对一个对象上锁不行,假使对a上锁,线程2依旧可以调用b.transfer(),因为b对象没有被上锁,由于a和b都是Account类的对象,所以这里选择类锁,即可以保证线程1/线程2中transfer方法完全执行完毕。
