从零开始的并发世界生活-第六天

本文详细解析了Java并发编程中的synchronized关键字在方法和类上的作用,讨论了线程八锁的概念,以及变量的线程安全分析,涉及成员变量、局部变量和常见线程安全类的使用场景。通过实例分析,探讨了如何确保线程安全,包括竞态条件、银行转账示例中的线程同步解决方案。
摘要由CSDN通过智能技术生成

继续之前的例子,学习并发编程

1.synchronized加在方法上

synchronized加在方法上,就等价于synchronized锁住的是this对象:

class Room {
    private int i = 0;

    public void increment() {
        synchronized (this) {
            i++;
        }
    }

等价于:

class Room {
    private int i = 0;

    public synchronized void increment() {
            i++;
    }

synchronized加在静态方法上,效果是锁住类对象:

class Test{
	public synchronized static void test(){

	}
}

等价于

class Test{
	public static void test(){
		synchronized(Test.class){

		}
	}
}

2.线程八锁

其实就是考察synchronized锁住的是哪个对象,一共有8种情况,这里只挑有代表性的说:

  • 情况1:
    在这里插入图片描述
    这种情况下,第一个线程锁住的是Number类,而第二个线程锁住的是n1这个类,两个线程锁住的是不同的,所以不会出现阻塞情况,两个线程并行执行,而且由于a方法需要睡1s,所以是第二个线程执行完,然后第一个线程执行完,所以输出结果是“2,1”。

  • 情况2:
    在这里插入图片描述
    由于第一个线程和第二个线程都是对Number类加锁,所以两个线程会发生互斥,所以输出结果可能是1,2,也可能是2,1,也就是说:类锁只能有一把。

3.线程安全分析

(1)变量的线程安全分析

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

    • 如果他们没有共享,则线程安全
    • 如果它们被共享了,根据它们的状态是否能够改变,又分为两种情况:
      • 如果只有读操作,则线程安全
      • 如果有读写操作,那么这段代码就是临界区,需要考虑线程安全问题
  • 局部变量是否线程安全

    • 局部变量是线程安全的
    • 但局部变量引用的对象则未必
      • 如果该对象没有逃离方法的作用范围。它是线程安全的
      • 如果该对象逃离方法的作用范围,需要考虑线程安全

(2)下面对局部变量的线程安全进行分析

情况1:局部变量为基本数据类型:

现有下面的局部变量:

public static void test1(){
	int i = 10;
	i++;
}

如果有多个线程要对局部变量i进行读写操作,要不要对i加保护?
每个线程调用test1()方法时局部变量i,会在每个线程的栈帧内存中被创建多份,因此不存在共享,因此不存在共享。
回顾栈帧概念:

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存;
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。
  • 也就是:一个线程一个栈,一个方法是一个栈中的栈帧
    两个栈帧中的内容并不是共享的,所以不会互相影响
    在这里插入图片描述
    上面的局部变量引用的是基本数据类型,而如果是引用的对象,那么情况就又不一样了。
情况2:局部变量为引用类型

首先来看一个成员变量的例子:
有一个list集合类型的成员变量:
在这里插入图片描述
执行method1会发生线程安全问题:
在这里插入图片描述
因为当类被创建时,成员对象是存放在堆中的,而不是存放在栈中,所以两个线程共用一个成员对象,所以会发生线程安全问题。
如果这个时候将成员变量改为局部变量呢:
在这里插入图片描述
这个时候是没有问题的!因为:

  • list是局部变量,每个线程调用时会创建其不同实例,没有共享
  • 而method2的参数是从method1中传递过来的,与method1中引用同一个对象
  • method3的参数分析与method2相同
    在这里插入图片描述
    如果这个时候局部变量引用的对象暴漏给外部会发生什么?
    暴漏情况1:
    在这里插入图片描述
    如果method2和method3是public,当线程1正在执行method1的时候,有一个线程2突然闯入执行method2,会导致线程1执行出错吗?
    答案是不会的,如果线程2执行method2能使线程1出错,那说明线程2执行的method2传入的list就是线程1中创建的list,但是线程2获取不到线程1中的list,所以不会发生线程安全问题。其实本质就是看资源会不会被共用。
    暴露情况2:
    如果ThreadSafe有一个子类继承并重写了method3:
    在这里插入图片描述
    这个时候创建ThreadSafeSubClass对象并创建一个线程t1运行method1,此时就可能出现线程安全问题,因为method3中新建了一个线程t2,与t1共享了list,就造成了线程安全问题。
    我们可以将方法访问修饰符改为private,并将method1设为final,防止子类重写方法而导致的线程安全问题。
    从这个例子就能看出private或final提供【安全】的意义所在,即开闭原则中的【闭】。

(2)常见线程安全类

  • String
  • Integer、Boolean等等包装类
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent包下的类
    这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的,也可以理解为:
  • 它们的每个方法是原子的(不过String是因为加了final修饰为不可变,所以线程安全)
  • 但它们多个方法的组合不是原子的
    比如Hashtable,为什么Hashtable是线程安全的?因为它每个方法都是原子性的。
    而Hashtable中方法的组合并不是原子的,分析下面这段代码是线程安全的吗?:
    在这里插入图片描述
    答:线程不安全,因为可能有以下情况导致最终结果有误:
    在这里插入图片描述
    String和Integer保证线程安全的方式是将值设置为不可变,但是String的substring和replace方法不是也能改变值吗?这些方法又是怎么在修改值的同时保证线程安全的呢?
    点击String的substring方法中:
    在这里插入图片描述
    红框部分就是判断如果需要截取字符串,就new一个新的String对象,并不是在原有的string对象上进行操作,没有改变原来string对象的值。

(3) 实例分析

例1:
Servlet时运行在tomcat中,只有一个实例,会被tomcat中多个线程共享,就会出现线程安全问题:
在这里插入图片描述
1.HashMap不是线程安全的,Hashtable才是线程安全的,所以第一个不安全
2.3.String是安全的
4.Date不是线程安全的
5.虽然被final修饰,但是这只是让D2引用指向的地址不可变了,但是这个地址内的存放的内容还是可变的,而String被final修饰不可变的原因是String类中的final是修饰的String的属性(String的底层存储结构式被final修饰的char型数组,是基本数据类型)。

例2:
在这里插入图片描述
这里判断UserService是否是线程安全的,明显不是,这就与我们上面所讲的引用类型的成员变量的线程安全的情况一样,MyServlet只有一个,那它的成员变量userService也只有一个,而且是存放在堆中,两个线程是共享这个堆中的唯一对象,所以是线程不安全的。
例3:
在这里插入图片描述
这段代码是加了@Aspect和@Component的,可见这是一段在Spring中的代码,在Spring容器中,没有加@Scope的对象都是单例对象,所以在线程中只有一个MyAspect对象,而start又是这个对象的成员变量,所以也只有一份并存放在堆中,所以不是线程安全的。
解释:虽然start是private的,但是可以通过调用before来实现对start的修改,又因为它是成员变量,不是局部变量,生命周期是从类的创建到消亡,因此在堆内存中只有一份。所以不是线程安全的
要解决这个例子中出现的问题,只要将start由成员变量变为局部变量即可。不能加上@Scope将其变为多例模式,否则可能在调用before和after方法时的start变量会不一致。
例4:
在这里插入图片描述
从下往上看,UserDaoImpl中没有成员变量,conn是局部变量,局部变量一般没有线程安全问题,所以3是线程安全的。
再看UserServiceImpl中的,userDao虽然是成员变量,但是其代码中并没有可以被线程共享的资源,所以其也是线程安全的,第1个userService同理,也是线程安全的,所以这三个地方都是线程安全的。
例5:
在这里插入图片描述
这个例子和上面的例4就不一样了,conn是成员变量,MyServlet只有一份,那userService也只有一份,那userDao也只有一份,那conn也只能有一份,所以是线程不安全的

例6:
在这里插入图片描述
通过之前例子的讲解很明显可以推断出例6是线程安全的,但是这种写法是不推荐的,最好把conn做成局部变量。
例7:
在这里插入图片描述
对然sdf是局部变量,但并不是说局部变量就无敌了,如果局部变量暴漏给其他的线程,也可能被其他线程“抓住把柄”将局部变量修改从而导致线程不安全问题。比如这里的抽象方法foo,就有可能被重写为在foo方法中加入创建新线程的方法体,然后此新建的线程就可能对sdf进行修改,从而导致线程不安全:
在这里插入图片描述
foo的行为是不确定的,可能导致不安全的发生,被称之为外星方法。

案例:

  • 案例1:
    编写一个模拟售票窗口售票的代码,并分析其中可能存在的问题:
public class ExerciseSell {
    public static void main(String[] args) {
        TicketWindow ticketWindow = new TicketWindow(2000);
        List<Thread> list = new ArrayList<>();
        // 用来存储买出去多少张票
        List<Integer> sellCount = new Vector<>();
        for (int i = 0; i < 2000; i++) {
            Thread t = new Thread(() -> {
                // 分析这里的竞态条件
                int count = ticketWindow.sell(randomAmount());
                sellCount.add(count);
            });
            list.add(t);
            t.start();
        }
        list.forEach((t) -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        // 买出去的票求和
        log.debug("selled count:{}", sellCount.stream().mapToInt(c -> c).sum());
        // 剩余票数
        log.debug("remainder count:{}", ticketWindow.getCount());
    }

    // Random 为线程安全
    static Random random = new Random();

    // 随机 1~5
    public static int randomAmount() {
        return random.nextInt(5) + 1;
    }
}

class TicketWindow {
    private int count;

    public TicketWindow(int count) {
        this.count = count;
    }

    public int getCount() {
        return count;
    }

    public int sell(int amount) {
        if (this.count >= amount) {
            this.count -= amount;
            return amount;
        } else {
            return 0;
        }
    }
}

上面的程序会出现线程安全问题,线程安全问题的出现就是资源的共享导致的,上面程序中可能出现问题的部分就是:
在这里插入图片描述
问题点1:ArrayList不是线程安全的,list.add会对list的内容造成修改,所以可能造成线程安全问题,但是这段代码中list.add(t)是在主线程中的,不是在new Thread中,所以不会被多个线程共享,所以这里是线程安全的
问题点2:Vector中的方法都是被synchronized修饰的,所以这里即使sellCount.add(count)是在new Thread中,也不会出现线程安全问题
问题点3:由于TicketWindow是唯一的,TicketWindow对象中的count是成员变量,ticketWindow.sell方法是对count进行修改的,并且ticketWindow.sell是执行在new Thread中的,所以会出现线程安全问题;只需要在TicketWindow类中的sell方法上加synchronized进行修饰即可保证线程安全(synchronized加载方法上就是相当远synchronized(this))。

  • 案例2:
    下面是一段模拟银行两个账户间转账的程序,判断是否存在线程安全问题:
public class ExerciseTransfer {
    public static void main(String[] args) throws InterruptedException {
        Account a = new Account(1000);
        Account b = new Account(1000);
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                a.transfer(b, randomAmount());
            }
        }, "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();
        // 查看转账2000次后的总金额
        log.debug("total:{}", (a.getMoney() + b.getMoney()));
    }

    // Random 为线程安全
    static Random random = new Random();

    // 随机 1~100
    public static int randomAmount() {
        return random.nextInt(100) + 1;
    }
}

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);
        }
    }
}

由于money是Account中的成员变量,并且被多个线程访问并使用transfer进行修改,所以会出现线程安全问题。
那怎么解决这个问题?
这里可能出现线程安全的问题不只是调用transfer方法的Account对象中的money会出现问题,而且transfer方法中传过来的target中的money也进行了修改,所以这里可能发生问题的成员变量有两个,一个是当前调用transfer方法的this对象的money,一个是传入transfer对象中的target对象中的money,之前的例子中我们都是使用synchronized锁住this对象就可以解决线程安全问题了,可是这里有两个对象,之前的方法显然不行了,怎么能同时锁住两个对象呢?
只需要将transfer改为下面这样即可:

public void transfer(Account target, int amount) {
        synchronized (Account.class){
            if (this.money > amount) {
                this.setMoney(this.getMoney() - amount);
                target.setMoney(target.getMoney() + amount);
            } 
        }
       
}

这样就锁住了Account这个类,只允许持有锁的线程执行transfer方法进行修改,不过这样并不是最好的解决方法,因为这样同一时间只能有一个线程修改Account类,也就是同一时间只能有一对用户进行转账,等这一对用户完成转账释放锁后下一对用户才能持有锁来进行转账,这显然是不合理的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值