首先确定好,线程安全跟前文学到的内容一致,即多个线程访问同一共享资源而又不加限制,则称为线程不安全。
注意:多个线程访问共享资源会出现线程安全问题,而共享资源在类中的体现即为对象的成员变量。所以实际考虑中,是多个线程访问同一实例对象产生的情况,因为假使不同的线程访问不同的对象,那么相当于一个线程拥有一份自己独有的对象,对象内的成员变量也是线程独自持有的,那么不涉及到访问共享资源的问题。而实际中,也是多个线程要访问同一实例对象,如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{
@Override
public 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 线程2
if(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
@Component
public 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 线程2
if(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方法完全执行完毕。