面试答题(摘抄)
- Java 中是如何支持正则表达式的
- Java中jdk 和 jre 的区别
- 变量及其作用范围
- Java 中equal 和 == 的区别
- Java 提供了哪几种循环结构?它们各自的特点是什么?
- 简述一下正则表达式及用途
- 比较一下Java和JavaScript的区别
- &与&&的区别
- String 和StringBuffer的区别
- Int 和 Integer 的区别
- 数组(Array)和列表(ArrayList)的区别?什么时候应该使用Array而不是ArrayList?
- 什么是值传递和引用传递
- Java8 的新特性
- “==”符号比较的是什么
- 访问权限修饰符
- final 关键字怎么用?
- hashCode() 和 equals() 方法的联系
- 什么是构造函数、什么是构造函数重载、什么是复制构造函数?
- 重载(Overload)和重写(Override)的区别
- Java 如何使用继承来重用代码?
- 简述 Java 中的多态
- 接口和抽象类的区别
- 静态内部类
- StringBuffer 和 StringBuilder 存在的作用是什么?
- 如何输出反转后的字符串
- 如何拷贝数组中的数据
- 二维数组的长度是否固定
- 什么是集合
- 什么是迭代器
- 比较器
- Vector 和 ArrayList 的区别
- 集合使用泛型有什么好处?
- 如何对集合中的元素进行排序
- 异常类
- IO 流
- 线程的实现方式
- 目录和文件操作
- 随机存储文件
- 字节流的处理方式
- 序列化和反序列化
- 多线程
不知道为什么目录不支持跳转了,将就看啦😭
Java 中是如何支持正则表达式的
-
Java中的String类提供了支持正则表达式操作的方法,包括:matches()、replaceAll()、replaceFirst()、split()。此外,Java中可以用Pattern类表示正则表达式对象,它提供了丰富的API进行各种正则表达式操作
import java.util.regex.*; public class RegExpTest { public static void main(String[] args) { String str = "成都市(成华区)(武侯区)(高新区)"; Pattern p = Pattern.compile(".*?(?=\\()"); Matcher m = p.matcher(str); if(m.find()){ System.out.println(m.group()); } } }
Java中jdk 和 jre 的区别
- JDK 是Java 开发工具,它不仅提供了 Java 运行所需的JRE ,还提供了一系列的编译、运行等工具;JRE 只是 Java程序的运行环境,它最核心的内容就是 JVM.
变量及其作用范围
在 Java中,变量根据生成周期的不同可以分为静态变量、成员变量以及局部变量三种。
- 静态变量就是指使用 static 修饰的变量,随着类的加载而加载
- 成员变量是在类中没有使用 static 修饰的变量,它属于该类的某个实例,随着对象的加载而初始化,随着对象的回收而消失
- 局部变量是定义在方法中的变量或方法中的参数,它们随着方法的调用而创造,随着方法的执行完毕而消失
Java 中equal 和 == 的区别
equal 和 “==” 两者都是表示相等的意思,但是它们相等的含义却有所区别
- “==” 运算在基本数据类型的时候,比较它们实际的值是否相同;而用于比较引用类型的时候,则是比较两个引用的地址是否相等,是否指向同一对象
- equal 方法是 java.lang.Object 的方法,它可以被重写,通过自定义的方法来判断两个对象是否相等。对于字符串 java.lang.String 类来说,它的 equal 方法用来比较字符串的字符序列是否完全相等
Java 提供了哪几种循环结构?它们各自的特点是什么?
Java 提供了三种循环结构,for
、while
、do···while
语句。它们各自适用于不同的情况
for
循环使用于能确定循环次数的循环结构while
则适用于单条语句的循环do···while
在执行某段语句之后,再循环的时候
简述一下正则表达式及用途
- 我们在编写处理字符串的程序时,经常会查找某些复杂规则字符串的需要,正则表达式就是在进行字符串匹配和处理时候最为强大的工具,测试字符串内的模式、替换文本、基于模式匹配从字符串中提取子字符串。
比较一下Java和JavaScript的区别
-
JavaScript 与Java是两个公司开发的不同的两个产品,Java是由Sun Microsystems公司推出的面向对象的程序设计语言;而JavaScript是Netscape公司的产品,JavaScript的前身是LiveScript;而Java的前身是Oak语言。
1.基于对象和面向对象:Java是真正的面向对象语言;JavaScript是脚本语言
2.解释和编译:Java的源代码在执行之前,必须经过编译。JavaScript是一种解释性编程语言。
&与&&的区别
- &运算符有两种用法:(1)按位与;(2)逻辑与。&&运算符是短路与运算。逻辑与跟短路与的差别是非常巨大的,虽然二者都要求运算符左右两端的布尔值都是true整个表达式的值才是true。&&之所以称为短路运算是因为,如果&&左边的表达式的值是false,右边的表达式会被直接短路掉,不会进行运算。很多时候我们可能都需要用&&而不是&,例如在验证用户登录时判定用户名不是null而且不是空字符串,应当写为:username != null &&!username.equals(""),二者的顺序不能交换,更不能用&运算符,因为第一个条件如果不成立,根本不能进行字符串的equals比较,否则会产生NullPointerException异常。
String 和StringBuffer的区别
- String类提供了数值不可改变的字符串,StringBuffer类提供的字符串可进行修改。
Int 和 Integer 的区别
- Java提供了两种不同的类型:引用类型和原始类型(内置类型)。Int 是Java的原始数据类型,Integer 是Java为 Int 提供的封装类。
数组(Array)和列表(ArrayList)的区别?什么时候应该使用Array而不是ArrayList?
区别:
-
Array 可以包含基本类型和对象类型,ArrayList 只能包含对象类型
-
Array 大小是固定的,ArrayList 的大小的动态变化的
-
ArrayList 提供了更多的方法和特性,比如:addAll(),iterator()等等
对于基本类型数据,集合使用自动装箱来减少编码的工作量。但是,当处理固定大小的基本数据类型的时候,这种方式比较慢
什么是值传递和引用传递
-
值传递是对基本型变量而言的,传递的是该变量的一个副本,改变副本不影响原变量
-
引用传递一般是对于对象型变量而言的,传递的是该对象地址的一个副本,并不是原对象本身。所以对引用对象操作会同时改变原对象
一般认为,Java内的传递都是值传递
Java8 的新特性
-
Lambda 表达式--Lambda 允许把函数作为一个方法的参数(函数作为参数传递进方法中)
-
方法引用--方法引用提供了非常有用的语法,可以直接引用已有Java类或对象(实例)的方法和构造器。与lambda 联合使用,方法引用可以使语言的构造更紧凑简介,减少冗余代码
-
默认方法--默认方法就是一个在接口里面有了一个实现的方法
-
新工具--新的编译工具,比如:Nashorn 引擎 jjs 、 类依赖分析器jdeps
-
Stream-API--新添加的Stream API(java.util.stream)把真正的函数式编程风格引入到Java中
-
Date Time API--加强对日期与时间的处理
-
Optional 类--Optional 类已经成为Java8 类库的一部分,用来解决空指针异常
“==”符号比较的是什么
- “”对比两个对象基于内存引用,如果两个对象的引用完全相同时,“”返回true,反之false。
- “==”如果两边是基本类型,就是比较数值是否相等
访问权限修饰符
final 关键字怎么用?
- 当 final 修饰一个类时,表明这个类不能被继承。final 类中的所有成员方法都会被隐式地指定为 final 方法
- 对于 final 变量,如果是基本数据类型的变量,则其数值一旦被初始化之后就不能更改;如果是引用类型的变量,则在对其初始化之后便不能再让其指向另外一个对象
hashCode() 和 equals() 方法的联系
- 相等的对象必须具有相等的哈希码
- 如果两个对象的 hashCode 相同,它们并不一定相同
什么是构造函数、什么是构造函数重载、什么是复制构造函数?
- 当新对象被创建时,构造函数就会被调用。每一个类都有一个默认的构造函数
- 构造函数重载和方法重载类似,可以为一个类创建多个构造函数,每个构造函数都有自己唯一得参数列表
- Java 不会默认创建构造函数
重载(Overload)和重写(Override)的区别
- 方法的重载和重写都是实现多态的方式,重载实现的是编译时的多态性,重写实现的是运行时的多态性。同名的方法如果有不同的参数列表(参数类型不同、参数个数不同)视为重载;重写要求子类与父类有相同的返回类型
Java 如何使用继承来重用代码?
- Java 采用单继承制,使用 extend 关键字,通过继承后,子类就拥有了父类除私有方法外的所有成员,从而达到代码重用的目的,在继承过程中,可以使用重写来实现多态,让子类拥有自己独特的方法来实现自己的方式
简述 Java 中的多态
- “多态” 按照字面意思来理解就是“多种形式,多种状态”。它的本质是发送信息给某个对象,让该对象来自行决定响应何种行为。通过将子类对象引用赋值给超类对象引用变量来实现多态的调用
接口和抽象类的区别
- 抽象类是一种功能不完全的类,接口只是一个抽象方法声明和静态不能被修改的数据的集合,两者都不能被实例化。从某种意义上来说,接口是一种特殊形式的抽象类,在 Java 语言中,抽象类表示一种继承关系,一个类只能继承一个抽象类,而一个类却可以实现多个接口
静态内部类
-
使用 static 关键字修饰的内部类
package abc class Outter{ //定义外部类 Outter static class inner{ //定义静态内部类 inner } }
- 在外部类加载的时候,静态内部类也随之加载。由于静态内部类是静态的,所以它无法访问外部类的静态成员。静态内部类对于外部类来说,几乎是独立的,它可以在没有外部类对象的情况下,单独创造一个内部类的对象
-
静态内部类相对于外部类来说,仅仅是包含关系,缩小了命名空间,完整类名中多了一个外部类的名称。本质上是两个独立的类, JVM 也不知道它们两个有包含关系
StringBuffer 和 StringBuilder 存在的作用是什么?
- 在 Java 程序中,如果有大量拼接字符串的需要,应该使用 StringBuffer 和 StringBuilder 类,它们可以避免不必要的 String 对象的产生,以提高程序的性能。它们两者的作用类似,而 StringBuilder 线程是不安全的
如何输出反转后的字符串
class Untitled {
public static void main(String[] args) {
String s = "If you are always there I will always love you";
System.out.println("原始的字符串:" + s);
System.out.println("反转后的字符串:");
for (int i =s.length(); i>0;i--){
System.out.print(s.charAt(i-1));
}
}
}
这是常用的方法,反向取出每个位置的字符串,然后将它们打印到控制台上,但是 Java 是一种提供多种现成共能得语言,使用 StringBuffer 可以更容易实现
class Untitled {
public static void main(String[] args) {
String s = "If you are always there I will always love you";
System.out.println("原始的字符串:" + s);
System.out.println("反转后的字符串:");
StringBuffer sb = new StringBuffer(s);
System.out.print(sb.reverse().toString());
}
}
如何拷贝数组中的数据
拷贝数组应该使用 System.arraycopy()
方法
public class ArrayCopy{
public static void main(String[] args){
int[] arr = new int[]{1,2,3};
int[] arr2 = new int[3];
System.arraycopy(arr,0,arr2,0,arr.length); //深拷贝数组
arr2[2] = 7;
for(int i:arr){
System.out.println(i); //1 2 3
}
for(int i:arr2){
System.out.println(i); //1 2 7
}
}
}
二维数组的长度是否固定
public class MultiDiArray {
public static void main(String[] args) {
int[][] arr = new int[3][];
arr[0] = new int[]{4};
arr[1] = new int[]{4, 5};
arr[2] = new int[]{2, 3, 4};
for (int[] a : arr) {
for (int i : a) {
System.out.print(i + "\t");
}
System.out.println();
} //4
//4 5
//2 3 4
}
}
- 长度不固定。Java 数组的长度是可以动态变化的,可以任意拓展数组的维度,每一维度的元素个数都可以不尽使用
什么是集合
- 集合是用来也只能用来存储其它对象的对象,代表了一种底层结构,用于拓展数组的功能。集合框架由一系列的接口和实现类组成,基本包括:
- 列表(List)
- 集合(Set)
- 映射(Map)
什么是迭代器
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class IteratorTest {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
Iterator<String> it = list.iterator(); //得到list的迭代器
//调用的迭代器的 hasNext() 方法,判断是否有下一个元素
while(it.hasNext()){
System.out.println(it.next());
}
}
}
- 迭代器,提供一种访问一个集合对象中各个元素的途径,同时又不需要暴露该对象的内部细节。Java 通过提供 Iterable 和 Iterator 两个接口来实现集合类的可迭代性。迭代器主要的用法就是,首先用 hasNext() 作为循环条件;再用 next() 方法得到每一个元素;最后再进行相关操作
比较器
-
对于 Comparable 接口来说,它往往是进行比较类需要实现的接口,它仅包含有一个 compareTO 方法,只有一个参数,返回值为 int 型数据。返回值大于0时,则表示本对象大于参数对象,小于0时,则表示本对象小于参数对象,等于0则表示两者相等
public class ComparableUser implements Comparable{ private String id; private int age; public ComparableUser(String id, int age) { this.id = id; this.age = age; } public String getId() { return id; } public void setId(String id) { this.id = id; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } @Override public int compareTo(Object o) { return this.age - ((ComparableUser) o).getAge(); } public static void main(String[] args) { ComparableUser user1 = new ComparableUser("u01",25); ComparableUser user2 = new ComparableUser("u01",26); if(user1.compareTo(user2)>0){ System.out.println("用户1大于用户2"); }else if(user1.compareTo(user2)<0){ System.out.println("用户1小于用户2"); }else{ System.out.println("用户1和用户2一样大"); } } }
-
Comparator 也是一个接口,它的实现者被称为比较器,它包含一个 compare() 方法,有两个参数,返回值与 Comparable() 的compareTo() 方法一样。不同之处就是 Comparator 接口一般不会被集合元素类所实现,而是单独实现或者用匿名内部类的方式实现
从Java5.0开始,Comparable 和 Comparator 接口都支持泛型,不需要进行类型转换
import java.util.Comparator; public class User { private String id; private int age; public User(String id, int age) { this.id = id; this.age = age; } public String getId() { return id; } public void setId(String id) { this.id = id; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } /*测试方法*/ public static void main(String[] args) { User u1 = new User("u01", 24); User u2 = new User("u02", 24); Comparator comp = new UserComparator(); int rst = comp.compare(u1, u2); if (rst > 0) { System.out.println("用户1大于用户2"); } else if (rst < 0) { System.out.println("用户1小于用户2"); } else { System.out.println("用户1等于用户2"); } } } class UserComparator implements Comparator<User> { @Override public int compare(User o1, User o2) { User u1 = (User) o1; User u2 = (User) o2; return u1.getAge() - u2.getAge(); } }
-
比较器是把集合或数组的元素强行按照指定方法进行排序的对象,它是实现了 Comparator 接口的实例。如果一个集合元素的类型是可比较的(实现了 Comparable 接口),那么它就具有默认的排序方法,比较器则是强行改变它默认的比较方式来进行排序。
Vector 和 ArrayList 的区别
public class ListTest {
public static void main(String[] args) {
Vector<String> v = new Vector<String>();
v.add("hello");
v.remove("hello");
System.out.println(v.size());
ArrayList<String> al = new ArrayList<String>();
al.add("world");
al.remove("world");
System.out.println(al.size());
}
}
- Vector 是线程安全的,因为它操作元素的方法都是同步方法,而 ArrayList 则不是。开发过程中根据需求进行选择,如果需要保证线程安全的地方则需要使用 Vector,而不必要的时候可以使用 ArrayList ,因为它的效率会高一些
集合使用泛型有什么好处?
- 集合使用泛型后,可以达到元素类型目的明确,避免了手动转换类型的过程,也让开发者明确了容器保存的什么类型的数据
如何对集合中的元素进行排序
- 如果列表中的元素都是相同类型的,并且这个类实现了 Comparable 接口,可以简单的调用 Collections.sort(),如果这个类没有实现 Comparator,就可以传递一个 Comparator 实例作为 sort() 的第二个参数进行排序。
异常类
- Throable
- Error:系统无法控制的严重异常。例如内存不足
- RunningException:
- 空指针异常、数组下标越界异常、算数异常、文件找不到异常、类型转换异常
- 异常的处理:
- throw:用在方法体内,抛出异常对象,可以抛出一个
- throws:用在方法体外,异常类,可以多个
IO 流
线程的实现方式
- 继承 Thread 类
- 实现 Runnable 接口
- 通过 Callerble 实现
- 线程的状态:
- 新建--就绪--运行--死亡
- sleep--休眠--休眠后进入就绪状态
- join--加入--等待状态
- 线程的通信:
- wait() -- 释放锁 -- 等待状态
- notify -- 唤醒一个释放锁等待状态的线程 -- 被唤醒的线程进入就绪状态 -- 准备抢占 cpu
- notifyAll() -- 唤醒所有释放锁等待状态的线程 -- 被唤醒的线程进入就绪状态 -- 准备抢占 cpu
目录和文件操作
public class FileDirTest {
public static void main(String[] args) {
File file = new File("D:\\notepad++\\readme.txt");
if (!file.exists()) { //判断是否已经存在
try {
file.createNewFile(); //创建新文件
} catch (IOException e) {
e.printStackTrace();
}
}
File dir = new File("D:\\javaDemo\\TestDemo\\src\\BoXueGu");
if (dir.isDirectory()) { //判断是否为目录
String[] files = dir.list(); //调用list()方法获取它的文件
for (String s : files) {
//用目录和文件名生成file对象
File f = new File(dir.getPath() + File.separator + s);
if (f.isFile()) {
System.out.println("file:" + f.getName());
} else if (f.isDirectory()) {
System.out.println("dir:" + f.getName());
}
}
}
}
}
- isDirectory() 和 isFile() 方法:用于检查该 File 对象所代表的是目录还是普通文件
- createNewFile() 方法:创建新文件,采用 File 对象所存储的路径和文件名进行创建
- list() 方法:用于目录,得到目录下所有的文件名,类型为字符串数组
- getName() 方法:得到文件名,不包含路径
- delete() 方法:删除文件
随机存储文件
-
使用 RandomAccessFile 的思路主要有一下几点:
- 用 length() 方法获取到文件的内容长度
- 用 seek() 方法随机的到达任何需要存取数据的地方
- 调用 read() 方法获取当前位置的数据,用 write() 方法写入数据
- 完成调用后,调用 close() 关闭文件
public class RanAccFileTest { public static void main(String[] args) { try { RandomAccessFile file = new RandomAccessFile("D:\\迅雷下载\\2021年最新鸡活方案汇总\\必看方案说明.txt", "rw"); for (int i = 0; i < file.length(); i++) { byte b = (byte) file.read(); char c = (char) b; if (c == '0') { file.seek(i); file.write('c'); } } file.close(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } }
字节流的处理方式
-
字节流最大的特点,就是每次的输出和输入都是一个字节。因此,它的主要应用在最原始的流的处理上,如内存缓存操作、文件复制
public class FileCopy { public static void main(String[] args) throws IOException { InputStream ist = new FileInputStream("D:\\javaDemo\\abc.txt"); OutputStream ost = new FileOutputStream("D:\\javaDemo\\ac.txt"); byte[] by = new byte[1024]; int len = 0; while ((len = ist.read(by)) != -1) { ost.write(by, 0, len); } ost.close(); ist.close(); } }
-
字符流是由字节流包装起来的,它的输入和输出流类型包括 StringReader 和 StringWriter、BuffereredReader 和 BufferedWriter
public class ReadTest { public static void main(String[] args) throws IOException { InputStream in = new FileInputStream("D:\\javaDemo\\abc.txt"); InputStreamReader isr = new InputStreamReader(in, "utf-8"); //InputStreamReader isr = new InputStreamReader(new FileInputStream("D:\\javaDemo\\abc.txt")); BufferedReader br = new BufferedReader(isr); //创建Reader StringBuffer sb = new StringBuffer(); //临时保存字符内容 String str = null; while ((str = br.readLine()) != null) { sb.append(str); } System.out.println("abc.txt:"+sb); br.close(); } }
序列化和反序列化
- 序列化,又称“串化”,本质上就是把对象中的数据按照一定的规则,变成一系列的字节数据,然后再把这些字节数据写入到流中。而反序列化的过程相反,先读取字节数据,然后再重新组装成 Java 对象所有需要进行序列化的类,都必须实现 Serializable 接口,必要时还需要提供静态的常量 seriaViersionUID
序列化最重要的作用:在传递和保存对象时,保证对象的完整性和可传递性。对象转换为有序字节流,以便于在网络上传输或者保存在本地文件中
反序列化最重要的作用:根据字节流中保存的状态及描述信息,通过反序列化重建对象
class student implements Serializable {
//序列化ID
private static final long serialVersionUID = -4526146658500473902L;
private String name;
private int age;
public student() {
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
//建议学生类新建一个Java文件进行编写,我只是为了方便,对于序列化ID不知道怎么生成的可以自行百度,需要在 idea 里面勾选一个选项即可
public class SerialTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
student stu = new student();
stu.setAge(20);
stu.setName("小明");
//创建对象输出流
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:\\javaDemo\\ac.txt"));
oos.writeObject(stu);
oos.close();
//创建一个对象输入流
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:\\javaDemo\\ac.txt"));
//读出序列化的对象
Object obj = ois.readObject();
//进行类型转换
student stu1 = (student) obj;
//打印在控制台,检查序列化及反序列化是否成功
System.out.println("stu name is" + stu1.getName());
System.out.println("stu age is" + stu1.getAge());
}
}
对于对象的输出和输入,Java 的 I/O 体系中主要提供了 ObjectOutputStream 和 ObjectInputStream 两个类:
- 让需要序列化的类实现 java.io.Serializable 接口
- 提供静态的 long 型的常量 serialVersionUID
- 如果是序列化对象,则用一个输出流创建一个ObjectOutputStream对象,然后调用 writeObject() 方法
- 如果是反序列化,首先使用一个输入流创建一个 ObjectInputStream 对象。然后调用 readObject() 方法,得到一个 Object 类型的对象。最后再做类型的强制转换
- 最后关闭流
多线程
- 多线程是为了使得多个并行的工作可以完成多项任务,以提高系统的效率。多线程进制下的线程彼此之间独立,比较容易共享数据,通过并发执行的方式来提高程序的效率和性能,使用多线程能带来以下好处:
- 使用线程可以把占据长时间的程序中的任务放到后台去处理
- 用户界面可以更加吸引人(自己体会)
- 程序的运行速度可能加快,人多力量大
- 在一些等待的任务实现上如果用户输入、文件读写和网络传输数据等,线程就比较空闲了。在这种情况下可以释放一些资源,如内存占用
1、如何让一个类成为线程类
-
一个是实现 java.lang.Runnable 接口,另一个就是继承 java.lang,Thread 类
public class RunnTest implements Runnable{ public void run(){ System.out.println('thread running.....'); } } class ThreadTest extends Thread{ public void run(){ System.out.println("thread running....."); } }
- 线程类继承自 Thread 则不能继承其它类,而 Runnable 接口可以
- 线程类继承自 Thread 相对于 Runnable 来说,使用线程的方法更方便些
- 实现 Runnable 接口的线程类的多个线程,可以更方便的访问同一变量,而 Thread 类则需要内部类来替代
2、如何使用 synchronized 实现线程同步
-
synchronized 的工作原理:每一个对象都有一个线程锁,synchronized 可以用任何一个对象的线程锁来锁住一段代码,任何想进入该段代码的线程都要在解锁后菜鸟继续执行,否则进入等待状态
class MyThread extends Thread { public static int index; //静态变量 public static Object obj = new Object(); //用任意一个对象来加锁 public void run() { synchronized (obj) { //为冲突加上同步代码块 for (int i = 0; i < 100; i++) { System.out.println(getName() + ":" + index++); } } } } public class SyncTest { public static void main(String[] args) { new MyThread().start(); new MyThread().start(); new MyThread().start(); new MyThread().start(); } }
synchronized 关键字代表要为某一段代码加上一个同步锁,这样的锁是绑定在某一个对象上边的。如果是同步代码块,需要为该synchronized 关键字提供一个对象的引用;如果是同步方法,只需要加一个 synchronized 关键字修饰