首先确定好,线程安全跟前文学到的内容一致,即多个线程访问同一共享资源而又不加限制,则称为线程不安全。

注意:多个线程访问共享资源会出现线程安全问题,而共享资源在类中的体现即为对象的成员变量。所以实际考虑中,是多个线程访问同一实例对象产生的情况,因为假使不同的线程访问不同的对象,那么相当于一个线程拥有一份自己独有的对象,对象内的成员变量也是线程独自持有的,那么不涉及到访问共享资源的问题。而实际中,也是多个线程要访问同一实例对象,如String中的bean对象都是单例模式,那么需要用到此对象时,就相当于多个线程共享一份对象,此时需要考虑线程安全问题。

成员变量和静态变量是否线程安全?

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根据它们的状态是否能够改变,又分为两种情况

    1. -如果只有读操作,则线程安全<br /> -如果有读写操作,则这段代码是临界区,需要考虑线程安全

详细解释上述两种变量,首先类的静态变量,是类持有的,类中任何对象以及类本身可以直接访问该静态对象,所以多个线程中的类对象可以直接访问,则会存在线程不安全的情况,比如:

  1. public class Test8Blocks {
  2. public static void main(String[] args) {
  3. Number n1 =new Number();
  4. Number n2 = new Number();
  5. new Thread(()->{
  6. Number.length=3;
  7. }).start();
  8. new Thread(()->{
  9. Number.length=4;
  10. }).start();
  11. }
  12. }
  13. class Number{
  14. public static int length = 1;
  15. }

即两个线程同时访问类的共享静态变量,则造成线程不安全。

其次是类的成员变量,成员变量也可以在多个线程中共享访问,比如只有访问同一对象实例的成员变量,那么这块变量也是线程共享的,则会出现线程不安全的问题,如下:

  1. public class Test8Blocks {
  2. public static void main(String[] args) {
  3. Number number = new Number();
  4. new Thread(()->{
  5. number.setLength(2);
  6. }).start();
  7. new Thread(()->{
  8. number.setLength(3);
  9. }).start();
  10. }
  11. }
  12. class Number{
  13. public int length;
  14. public int getLength() {
  15. return length;
  16. }
  17. public void setLength(int length) {
  18. this.length = length;
  19. }
  20. }

可以看到多个线程仍访问一块共享资源,即一个类对象的一个成员变量,故可能会引发线程不安全问题。

局部变量是否线程安全?

  • 局部变量是线程安全的
  • 但局部变量引用的对象未必

    1. -如果该对象没有逃离方法的作用访问,它是线程安全的<br /> -如果该对象逃离方法的作用范围,需要考虑线程安全

    一、线程安全问题分析

1. 普通局部变量

  1. public static void test1(){
  2. int i=10;
  3. i++;
  4. }

如果多个线程调用test1方法不会有线程安全问题,因为每个线程会有自己的栈区,且栈帧对应调用的方法,所以局部变量i有多份,每份存在所属线程的栈帧中,所以并不会被多个线程共享。

总结:一般的局部变量都不会有线程安全问题

2. 引用局部变量(在方法内部不将变量引用暴露出去)

  1. import java.util.ArrayList;
  2. public class LocatedVia {
  3. static final int THREAD_NUMBER = 2;
  4. static final int LOOP_NUMBER = 200;
  5. public static void main(String[] args) {
  6. ThreadSafe test = new ThreadSafe();
  7. for(int i=0;i<THREAD_NUMBER;i++){
  8. new Thread(()->{
  9. test.method1(LOOP_NUMBER);
  10. },"Thread"+(i+1)).start();
  11. }
  12. }
  13. }
  14. class ThreadSafe{
  15. public final void method1(int loopNumber){
  16. ArrayList<String> list = new ArrayList<>();
  17. for(int i=0;i<loopNumber;i++){
  18. method2(list);
  19. method3(list);
  20. }
  21. }
  22. private void method2(ArrayList<String> list){
  23. list.add("1");
  24. }
  25. private void method3(ArrayList<String> list){
  26. list.remove(0);
  27. }
  28. }

本例中,两个线程调用方法时,list成为了方法method1内部的局部变量,所以每个线程都会在堆中创建一个list实例,此种情况没有将list引用暴露出去,没有逃离方法的作用访问。
示意图如下:
image.png

3. 引用局部变量(在方法内部将变量引用暴露出去)

  1. import java.util.ArrayList;
  2. public class LocatedVia {
  3. static final int THREAD_NUMBER = 2;
  4. static final int LOOP_NUMBER = 200;
  5. public static void main(String[] args) {
  6. ThreadSafeSubClass test = new ThreadSafeSubClass();
  7. for(int i=0;i<THREAD_NUMBER;i++){
  8. new Thread(()->{
  9. test.method1(LOOP_NUMBER);
  10. },"Thread"+(i+1)).start();
  11. }
  12. }
  13. }
  14. class ThreadSafe{
  15. public final void method1(int loopNumber){
  16. ArrayList<String> list = new ArrayList<>();
  17. for(int i=0;i<loopNumber;i++){
  18. method2(list);
  19. method3(list);
  20. }
  21. }
  22. public void method2(ArrayList<String> list){
  23. list.add("1");
  24. }
  25. public void method3(ArrayList<String> list){
  26. list.remove(0);
  27. }
  28. }
  29. class ThreadSafeSubClass extends ThreadSafe{
  30. @Override
  31. public void method3(ArrayList<String> list){
  32. new Thread(()->{
  33. list.remove(0);
  34. });
  35. }
  36. }

此例中,当某一线程调用method3方法时,由于ThreadSafeSubClass类中的method3方法重写了父类的方法,所以实际调用的是子类中的method3方法,而该方法将引用变量list暴露给了新创建的线程,相当于其他线程中也访问了list对象,出现线程安全问题。

注意本例中父类的method2和method3方法均是public,故可以被子类继承。如果父类中的方法设为private,则不可被子类重写(父类私有不可被子类继承和重写),一定程度上可以解决线程安全问题。
也可以将方法定义为final,防止子类影响本方法。

4. 成员变量

  1. import java.util.ArrayList;
  2. public class LocatedVia {
  3. static final int THREAD_NUMBER = 2;
  4. static final int LOOP_NUMBER = 200;
  5. public static void main(String[] args) {
  6. ThreadUnsafe test = new ThreadUnsafe();
  7. for(int i=0;i<THREAD_NUMBER;i++){
  8. new Thread(()->{
  9. test.method1(LOOP_NUMBER);
  10. },"Thread"+(i+1)).start();
  11. }
  12. }
  13. }
  14. class ThreadUnsafe{
  15. ArrayList<String> list = new ArrayList<>();
  16. public void method1(int loopNumber){
  17. for (int i=0;i<loopNumber;i++){
  18. method2();
  19. method3();
  20. }
  21. }
  22. private void method2(){
  23. list.add("1");
  24. }
  25. private void method3(){
  26. list.remove(0);
  27. }
  28. }

本例中,主方法中创建两个线程,每个线程中调用ThreadUnsafe类中的method2方法和method3方法;
但注意两个线程使用的是同一个对象,所以本质上两个线程访问的是同一个对象的成员变量list,存在共享情况,会出现线程安全问题。
示意图如下:
image.png

二、常见线程安全类及其组合

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent包下的类

线程安全类是指,多个线程调用同一个实例(一定是同一个实例,很重要!)的某个方法时,是线程安全的,可理解为类的每个方法都是原子操作。调用的时候不用上锁,实际查看源码时,每个方法均声明成synchronized,synchronized目的即为保证操作的原子性。

但注意,每个方法是原子操作,但方法组合不是原子操作,依然会出现线程安全问题,如:

  1. Hashtable table = new Hashtable();
  2. //线程1 线程2
  3. if(table.get("key")==null){
  4. table.put("key",value);
  5. }

尽管get与put方法均是原子方法,但由于线程上下文切换,还是会造成线程安全问题。比如线程1刚执行到get方法,切换到线程2执行put方法,线程1回来再执行put方法,则值不对,相当于put两遍。
image.png

题外话:这里学的基础背景是,不同线程访问共享资源,而访问共享资源的形式是通过类的方法访问,所以判断是否线程安全,要看所调用的方法内部是否对共享资源执行了原子操作。
这里不同线程调用的是同一个实例对象,因为调用一个实例对象才涉及到去更改该对象的成员变量等属性。否则如果不是同一个实例对象,那么多个线程互不干扰。

三、不可变类线程安全性

String、Interger都是不可变类,其内部状态不可改变,因此它们的方法都是线程安全的。

这句话怎么理解,如果是不可变类,拿String类为例,则相当于字符串不可变,那么即使多个线程访问同一个实例对象(共享资源)中的方法时,也不会出现线程安全问题,因为“读写”操作才会造成线程安全问题,只读不写不会产生问题。

四、线程安全实例分析

例1:

  1. class MyServlet extends HttpServlet{
  2. //是否安全?
  3. Map<String,Object> map = new HashMap<>();
  4. //是否安全?
  5. String s1="...";
  6. //是否安全?
  7. final String s2="...";
  8. //是否安全?
  9. Data D1=new Data();
  10. //是否安全?
  11. final Data D2=new Data();
  12. public void deGet(){
  13. //使用上述变量
  14. }
  15. }

Tomcat中Servlet对象只存在一份,所以Servlet对象会被多线程所共享,故会产生线程安全问题,而仅有一个对象,那么对象的成员变量也均会被共享,所以成员变量可能会造成线程安全问题。
本例中,Map容器会被多个线程所修改,所以是不安全的,s1与s2均是字符串对象,是不可变类对象,故线程安全,D1和D2均线程不安全,因为D1对象内部一些成员变量可被修改,即使D2由final修饰,也只是保证引用D2不变(即依旧指向同一块堆内存),但D2对象的成员变量仍然可变。

例2:

  1. class MyServlet extends HttpServlet{
  2. //是否安全?
  3. private UserService userService = new UserServiceImpl();
  4. public void doGet(){
  5. userService.update();
  6. }
  7. }
  8. class UserServiceImpl implements UserService{
  9. private int count=0;
  10. public void update(){
  11. count++;
  12. }
  13. }

由于MyServlet对象是单例,所以成员变量userService也是单例,故多个线程共享userService对象,在userService这一步,即使多个线程共享userService对象也是线程安全的,但userService对象有成员变量count,可能调用update方法对对象的成员count值做改变,所以是线程不安全的。

例3:

  1. @Aspect
  2. @Component
  3. public class MyAspect {
  4. //是否安全?
  5. private long start =0L;
  6. @Before("execution(* *(..))");
  7. public void before(){
  8. start = System.nanoTime();
  9. }
  10. @After("execution(* *(..))");
  11. public void after(){
  12. long end = System.nanoTime();
  13. System.out.println("cost time:"+(end-start));
  14. }
  15. }

本例中的应用场景是Spring框架中的AOP,且Spring中被@Component修饰的均为SpringBean对象,所以是单例模式,那么多个线程要共享这一份对象,与例2一样,start会被多个线程修改,所以线程不安全。

例4:

  1. public class MyServlet extends HttpServlet {
  2. //是否安全
  3. private UserService userService = new UserServiceImpl();
  4. public void doGet(){
  5. userService.update;
  6. }
  7. }
  8. class UserServiceImpl{
  9. //是否安全
  10. private UserDao userDao = new UserDaoImpl();
  11. public void update(){
  12. userDao.update();
  13. }
  14. }
  15. class UserDaoImpl implements UserDao{
  16. public void update(){
  17. String sql="update column set";
  18. try(Connection conn= DriverManager.getConnection("","","")){
  19. }
  20. catch (Exception e){
  21. }
  22. }
  23. }

从上往下分析,MyServlet单例,成员变量userService对象,UserServiceImpl类的成员变量userDao也被共享,再向下,UserDaoImpl类中没有成员变量,所以即使多个线程共享同一实例对象,也没有可以去并发更改的值,所以整体线程安全。

一般来说,没有成员变量,或者将成员变量改成局部变量,线程就是安全的。

例5:

  1. public class MyServlet extends HttpServlet {
  2. //是否安全
  3. private UserService userService = new UserServiceImpl();
  4. public void doGet(){
  5. userService.update;
  6. }
  7. }
  8. class UserServiceImpl{
  9. //是否安全
  10. private UserDao userDao = new UserDaoImpl();
  11. public void update(){
  12. userDao.update();
  13. }
  14. }
  15. class UserDaoImpl implements UserDao{
  16. private Connection conn = null;
  17. public void update() throws SQLException{
  18. String sql="update user set password =? where username =?";
  19. conn = DriverManager.getConnection("","","");
  20. // ...
  21. conn.close();
  22. }
  23. }

conn变成成员变量而不是局部变量,则多个线程调用时会出现问题,比如线程1调用对象的update方法后conn还没来得及写,线程2则将conn对象关闭,再回到线程1时候则会报错,conn为空。

例6(本例需要细品)

  1. public class MyServlet extends HttpServlet {
  2. //是否安全
  3. private UserService userService = new UserServiceImpl();
  4. public void doGet(){
  5. userService.update;
  6. }
  7. }
  8. class UserServiceImpl{
  9. public void update(){
  10. //是否安全
  11. UserDao userDao = new UserDaoImpl();
  12. userDao.update();
  13. }
  14. }
  15. class UserDaoImpl implements UserDao{
  16. private Connection conn = null;
  17. public void update() throws SQLException{
  18. String sql="update user set password =? where username =?";
  19. conn = DriverManager.getConnection("","","");
  20. // ...
  21. conn.close();
  22. }
  23. }

此例中,调用update方法时,每个线程会创建一个userDao对象,所以在这行代码之后,每个线程都拥有一个独有的UserDaoImpl类对象,不涉及到多个线程并发更改同一个UserDaoImpl类对象的成员变量conn的情况,所以本例线程安全。

例7(暴露引用):

  1. public abstract class Test {
  2. public void bar(){
  3. //是否安全
  4. SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
  5. foo(sdf);
  6. }
  7. public abstract foo(SimpleDateFormat sdf);
  8. public static void main(String[] args) {
  9. new Test().bar();
  10. }
  11. }

其中foo的行为是不确定的,可能导致不安全的发生,被称之为外星方法

  1. public void foo(SimpleDateFormat sdf){
  2. String dataStr = "1999-10-11 00:00:00";
  3. for(int i=0;i<20;i++){
  4. new Thread(()->{
  5. try {
  6. sdf.parse(dataStr);
  7. }
  8. catch (ParseException e){
  9. e.printStackTrace();
  10. }
  11. })
  12. }
  13. }

父类中,不知道子类如何实现foo方法,可以将sdf对象的引用暴露出去,即暴露给别的线程,那么相当于多个线程共享一个SimpleDataFormat类对象,而SimpleDataFormoat类对象内部的成员变量是可访问更改的,所以线程不安全。

再啰嗦一遍:线程安全问题是指多个线程共用一个实例对象,进而可能出现访问共享资源(对象的成员变量或类变量)指令交错的问题。

五、习题

1.卖票
image.png

本例中,多个线程的共享变量是window对象以及amoutList对象,所以访问这两个对象成员变量的代码可视为临界区,需要使用synchronized保护,临界区的定义为:对共享资源作读写操作的那段代码。

所以更改后的代码为:在window.sell()方法上加上synchronized,在add()上不需要加入synchronized,因为add方法本身就是线程安全的。

注意,是否需要考虑组合安全问题?

  1. int amount = window.sell(random(5));
  2. //统计卖票数
  3. amountList.add(thread);

不需要考虑组合安全,因为是两个对象的读写操作,不涉及到一个对象的语句组合问题。
如果按以下代码,可能会出现线程安全问题。

  1. Hashtable table = new Hashtable();
  2. //线程1 线程2
  3. if(table.get("key")==null){
  4. table.put("key",value);
  5. }

综上,本例总结,看线程是否安全,首先看线程共享的对象/变量.
(1)访问对象的成员变量等是否存在读写操作,读写那段代码成为临界区,需要保护
(2)组合代码是否涉及线程安全

2.转账

  1. import java.util.Random;
  2. public class ExerciseTransfer {
  3. public static void main(String[] args) {
  4. Account a = new Account(1000);
  5. Account b = new Account(1000);
  6. Thread t1 = new Thread(()->{
  7. for (int i=0;i<1000;i++){
  8. a.transfer(b,randomAmounnt());
  9. }
  10. },"t1");
  11. Thread t2 = new Thread(()->{
  12. for (int i=0;i<1000;i++){
  13. b.transfer(a,randomAmount());
  14. }
  15. },"t2");
  16. t1.start();
  17. t2.start();
  18. t1.join();
  19. t2.join();
  20. System.out.println("total:{}",(a.getMoney()+b.getMoney()));
  21. }
  22. }
  23. class Account{
  24. private int money;
  25. public Account(int money){
  26. this.money=money;
  27. }
  28. public int getMoney(){return money;};
  29. public void setMoney(int money){this.money=money;}
  30. //可知这段代码即为,临界区
  31. public void transfer(Account target,int amount){
  32. if(this.money>=amount){
  33. this.setMoney(this.getMoney()-amount);
  34. target.setMoney(target.getMoney()+amount);
  35. }
  36. }
  37. }

可以看出线程1与线程2访问的共享对象为a对象和b对象,共享资源为a.money与b.money,为了保证线程安全,要对临界区保护,保护的目标是使得临界区的代码由线程串行执行,所以要对临界区方法上锁,这时要考虑对象上锁还是类上锁。

  1. Thread t1 = new Thread(()->{
  2. for (int i=0;i<1000;i++){
  3. a.transfer(b,randomAmounnt());
  4. }
  5. },"t1");
  6. Thread t2 = new Thread(()->{
  7. for (int i=0;i<1000;i++){
  8. b.transfer(a,randomAmount());
  9. }
  10. },"t2");

a.transfer()与b.transfer(),明显只对一个对象上锁不行,假使对a上锁,线程2依旧可以调用b.transfer(),因为b对象没有被上锁,由于a和b都是Account类的对象,所以这里选择类锁,即可以保证线程1/线程2中transfer方法完全执行完毕。