继续之前的例子,学习并发编程
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类,也就是同一时间只能有一对用户进行转账,等这一对用户完成转账释放锁后下一对用户才能持有锁来进行转账,这显然是不合理的。