三七互娱Android面试题及参考答案
创建线程的方式有哪些?
在 Android 开发中,创建线程主要有以下几种方式。
一种是继承 Thread 类。首先创建一个类继承自 Thread 类,然后重写 run 方法。在 run 方法中定义线程需要执行的任务。例如:
class MyThread extends Thread {@Overridepublic void run() {// 这里是线程执行的代码for (int i = 0; i < 10; i++) {System.out.println("线程执行中:" + i);}}
}
然后在需要开启线程的地方,创建这个类的对象并调用 start 方法来启动线程,如:
MyThread myThread = new MyThread();
myThread.start();
另一种方式是实现 Runnable 接口。定义一个类实现 Runnable 接口,实现 run 方法。比如:
class MyRunnable implements Runnable {@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println("通过Runnable实现的线程执行中:" + i);}}
}
使用时,创建一个 Thread 对象,将实现 Runnable 接口的类的对象作为参数传入,再调用 start 方法,如下:
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
还可以使用匿名内部类的方式来创建线程。对于继承 Thread 类的匿名内部类,示例如下:
Thread thread = new Thread() {@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println("匿名内部类继承Thread的线程执行中:" + i);}}
};
thread.start();
对于实现 Runnable 接口的匿名内部类,可以这样写:
Runnable runnable = new Runnable() {@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println("匿名内部类实现Runnable的线程执行中:" + i);}}
};
Thread thread = new Thread(runnable);
thread.start();
在 Android 中,也可以使用 AsyncTask 来创建线程。AsyncTask 是一个抽象类,它使得在后台执行任务并在主线程更新 UI 变得更加方便。定义一个类继承 AsyncTask,需要指定三个泛型参数,分别是 Params、Progress 和 Result。Params 是传入到后台任务中的参数类型,Progress 是后台任务执行过程中发布进度的类型,Result 是后台任务执行完毕后返回结果的类型。例如:
class MyAsyncTask extends AsyncTask<Void, Integer, String> {// 在后台线程执行的方法,这里的参数类型是由第一个泛型参数决定的@Overrideprotected String doInBackground(Void... voids) {for (int i = 0; i < 10; i++) {// 可以通过publishProgress方法来更新进度publishProgress(i);try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}return "任务完成";}// 这个方法会在主线程中执行,用于更新进度,参数类型是由第二个泛型参数决定的@Overrideprotected void onProgressUpdate(Integer... values) {super.onProgressUpdate(values);System.out.println("进度更新:" + values[0]);}// 这个方法会在主线程中执行,用于处理后台任务返回的结果,参数类型是由第三个泛型参数决定的@Overrideprotected void onPostExecute(String s) {super.onPostExecute(s);System.out.println("任务结果:" + s);}
}
使用时,创建这个类的对象并调用 execute 方法,如:
MyAsyncTask myAsyncTask = new MyAsyncTask();
myAsyncTask.execute();
另外,在 Java 8 之后,可以使用 lambda 表达式结合 Runnable 接口来创建线程。例如:
Runnable runnable = () -> {for (int i = 0; i < 10; i++) {System.out.println("通过lambda表达式实现Runnable的线程执行中:" + i);}
};
Thread thread = new Thread(runnable);
thread.start();
线程池是怎么创建的?
在 Android 中,通常使用 Java 中的Executors
工厂类或者ThreadPoolExecutor
类来创建线程池。
使用Executors
工厂类创建线程池是比较方便的方式。例如,通过Executors.newFixedThreadPool(int nThreads)
可以创建一个固定大小的线程池。这个方法会返回一个ExecutorService
接口的实现类对象,其中nThreads
参数指定了线程池中的线程数量。这些线程在整个生命周期内都不会被销毁,它们会一直等待任务到来并执行。当提交的任务数量超过线程数量时,任务会在队列中等待,直到有空闲线程来执行。
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5);
还有Executors.newCachedThreadPool()
,它创建的是一个可缓存的线程池。这种线程池中的线程数量是不固定的。如果有新任务提交,并且没有空闲线程,就会创建新线程来处理任务。如果线程空闲了一段时间(默认 60 秒),就会被回收。
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
另外,Executors.newSingleThreadExecutor()
可以创建一个单线程的线程池。这个线程池只有一个工作线程,所有任务都按照提交的顺序依次执行。
ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
如果想要更灵活地控制线程池的参数,就可以直接使用ThreadPoolExecutor
类来创建。ThreadPoolExecutor
的构造函数有多个参数,包括核心线程数(corePoolSize
)、最大线程数(maximumPoolSize
)、线程存活时间(keepAliveTime
)、时间单位(unit
)、任务队列(workQueue
)等。例如:
int corePoolSize = 3;
int maximumPoolSize = 5;
long keepAliveTime = 1000L;
TimeUnit unit = TimeUnit.MILLISECONDS;
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(10);
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
这里的核心线程数是 3,最大线程数是 5,线程存活时间是 1 秒,任务队列是一个大小为 10 的ArrayBlockingQueue
。当提交任务时,首先会尝试使用核心线程来执行任务。如果核心线程都在忙,任务就会被放入任务队列。当任务队列满了,并且线程数没有达到最大线程数时,就会创建新线程来执行任务。当线程空闲时间超过存活时间,并且线程数大于核心线程数时,多余的线程就会被销毁。
如何考虑线程池中的线程数量(线程池参数设置)?
在考虑线程池中的线程数量时,需要综合多方面因素来设置参数。
首先是任务的性质。如果任务是 CPU 密集型的,比如进行复杂的数学计算、加密解密算法等,线程数量通常不宜过多。因为 CPU 核心数是有限的,过多的线程会导致大量的线程上下文切换开销。一般来说,线程数可以设置为 CPU 核心数加 1 或者等于 CPU 核心数,这样可以充分利用 CPU 资源,同时避免过多的上下文切换。例如,如果设备的 CPU 是四核的,那么线程池的核心线程数可以设置为 4 或者 5。
如果任务是 I/O 密集型的,比如网络请求、文件读取等,线程数量可以适当增加。因为在 I/O 操作时,线程会处于等待状态,此时可以让其他线程继续执行任务。可以根据 I/O 等待时间和 CPU 处理时间的比例来估算合适的线程数。假设 I/O 等待时间占总任务时间的 80%,CPU 处理时间占 20%,那么理论上可以设置线程数为 CPU 核心数的 5 倍左右。
其次是系统资源的限制。要考虑设备的内存等资源情况。如果线程数量过多,会占用大量的内存,可能导致内存不足或者系统性能下降。每个线程都有自己的栈空间,在 Android 中,默认的线程栈大小是有一定限制的,过多的线程会快速消耗内存。
还要考虑任务的并发量。如果预计同时提交的任务数量较多,那么需要设置合适的最大线程数和任务队列大小。如果最大线程数过小,任务队列可能会快速填满,导致新任务无法及时执行;如果最大线程数过大,可能会浪费系统资源。任务队列的类型也很重要,例如LinkedBlockingQueue
是无界队列,ArrayBlockingQueue
是有界队列,根据任务的特点选择合适的队列可以更好地控制任务的执行顺序和数量。同时,核心线程数的设置也很关键,核心线程数决定了在正常情况下线程池中有多少线程是一直保持活跃的。
并发修改异常通常发生在什么场景?
并发修改异常(ConcurrentModificationException)主要出现在对集合进行遍历操作的同时又对集合进行修改的场景。
以 ArrayList 为例,当使用迭代器(Iterator)遍历一个 ArrayList 时,如果在迭代过程中,通过原集合对象的方法对集合进行结构上的修改(如添加、删除元素),就很可能会抛出并发修改异常。比如在一个循环中遍历 ArrayList,同时在循环体内使用 ArrayList 的 add 或者 remove 方法。这是因为迭代器在迭代过程中有一个内部的修改计数,当集合自身的修改操作改变了这个计数,而迭代器的计数与之不匹配时,就会检测到并发修改异常。
在多线程环境下这种情况更容易出现。假设有多个线程同时访问和修改同一个集合。一个线程正在遍历集合,另一个线程在同一时刻对集合进行插入或删除操作。这种并发访问没有进行适当同步的情况下,就会引发并发修改异常。
对于一些基于哈希表结构的集合(如 HashMap),在遍历过程中,如果对其进行 rehash 操作(例如,当哈希表的负载因子达到一定阈值时会自动扩容和 rehash),并且同时有其他线程对其进行修改,也可能导致并发修改异常。
另外,在使用一些视图集合(例如通过 subList 方法得到的子列表)时,如果对原始集合进行修改,而视图集合的迭代器并不知道这种修改,也可能触发异常。这是因为视图集合的迭代器和原始集合的内部状态不一致导致的。
怎么确保 HashMap 的集合安全,避免出现异常?
要确保 HashMap 集合安全,避免异常,主要有以下几种方法。
一种是使用同步容器。在 Java 中,可以使用Collections.synchronizedMap(new HashMap<>())
来创建一个同步的 Map。这样,在多线程访问这个 Map 时,会通过内置的锁机制来保证同步。例如,当一个线程对这个同步 Map 进行 put 操作时,其他线程如果也要进行操作,就需要等待这个 put 操作完成。
另一种是使用并发容器。在 Java 并发包中,ConcurrentHashMap
是一个专门为并发场景设计的哈希表容器。它采用了更精细的锁机制,比如分段锁。与传统的对整个 HashMap 加锁不同,ConcurrentHashMap
将哈希表分成多个段,不同的段可以被不同的线程同时访问和修改,只有当多个线程访问同一个段时才会产生竞争和等待。这样大大提高了并发性能。
在使用HashMap
时,如果是单线程环境下,一般按照正常的操作流程(如正确的 put、get、remove 等操作),只要不违反其基本使用规则(如键不能为 null,正确处理哈希冲突等),就不会出现异常。但如果是多线程环境,要注意对共享的HashMap
进行同步访问。可以通过在访问HashMap
的代码块外添加synchronized
关键字来实现同步。不过这种方式会降低并发性能,因为所有对这个HashMap
的访问都需要排队等待锁的释放。
还可以使用读写锁(ReadWriteLock)来优化对HashMap
的并发访问。如果大部分操作是读操作,只有少量的写操作,那么使用读写锁可以提高并发性能。读操作可以同时进行,而写操作需要独占锁。这样可以在保证数据安全的同时,提高读取数据的效率。
通过 HttpURLConnection 获取网络资源的过程是怎样的?
首先,需要创建一个 URL 对象,将目标资源的网址作为参数传入。例如,如果要获取一个网页的内容,就把网页的 URL 地址传递给 URL 构造函数。
接着,通过 URL 对象的 openConnection 方法获取 HttpURLConnection 实例。这个实例代表了一个 HTTP 连接。在获取到连接后,需要设置一些请求属性。比如设置请求方法,通常是 GET 或者 POST 等。如果是 GET 方法,主要用于获取资源,一般不需要设置请求体;如果是 POST 方法,用于向服务器提交数据,可能需要设置请求体相关的内容。还可以设置请求头,像设置用户代理(User - Agent)来表明客户端的类型,设置连接超时时间和读取超时时间等,以避免长时间的等待导致程序卡顿。
在完成属性设置后,调用 connect 方法建立与服务器的实际连接。对于 GET 请求,连接建立后就可以通过 getInputStream 方法获取服务器返回的输入流。这个输入流包含了请求资源的内容,比如网页的 HTML 代码、图片数据或者其他文件内容等。然后可以使用字节流或者字符流的方式读取这个输入流,并将读取到的内容进行处理,例如将网页内容解析并展示。对于 POST 请求,在建立连接之前,还需要通过 getOutputStream 方法获取输出流,将需要提交的数据写入这个输出流后再进行连接和读取返回内容。
最后,在资源获取完成后,需要关闭连接。这是很重要的一步,因为如果不关闭连接,可能会导致资源浪费,比如占用网络端口、内存等资源。可以通过调用 disconnect 方法来关闭连接。
JSON 数据应该如何处理?
在 Android 中处理 JSON 数据,首先要了解 JSON 数据的结构。JSON 主要有两种结构:对象和数组。JSON 对象是一个无序的 “键 - 值” 对集合,键是字符串,值可以是字符串、数字、布尔值、JSON 对象、JSON 数组或者 null。JSON 数组是一个有序的值的集合,值可以是上述 JSON 对象中值的各种类型。
如果要解析 JSON 数据,当 JSON 数据是一个简单的字符串格式时,可以使用 Android 系统自带的 JSON 解析类,如 JSONObject 和 JSONArray。对于 JSON 对象,可以通过将 JSON 字符串传递给 JSONObject 的构造函数来创建一个 JSONObject 实例。例如,假设获取到一个表示用户信息的 JSON 对象字符串,可以这样处理:
String jsonObjectStr = "{\"name\":\"张三\",\"age\":20,\"address\":{\"city\":\"北京\",\"district\":\"朝阳区\"}}";
try {JSONObject jsonObject = new JSONObject(jsonObjectStr);String name = jsonObject.getString("name");int age = jsonObject.getInt("age");JSONObject addressObject = jsonObject.getJSONObject("address");String city = addressObject.getString("city");// 以此类推,可以获取其他键值对的值
} catch (JSONException e) {e.printStackTrace();
}
对于 JSON 数组,可以使用 JSONArray 类。假设获取到一个包含用户列表的 JSON 数组字符串:
String jsonArrayStr = "[{\"name\":\"张三\",\"age\":20},{\"name\":\"李四\",\"age\":22}]";
try {JSONArray jsonArray = new JSONArray(jsonArrayStr);for (int i = 0; i < jsonArray.length(); i++) {JSONObject userObject = jsonArray.getJSONObject(i);String name = userObject.getString("name");int age = userObject.getInt("age");// 处理每个用户对象的信息}
} catch (JSONException e) {e.printStackTrace();
}
除了手动解析,还可以使用一些第三方库来处理 JSON 数据,如 Gson。Gson 可以很方便地将 JSON 数据转换为 Java 对象,也可以将 Java 对象转换为 JSON 数据。首先需要在项目中引入 Gson 库,然后定义与 JSON 数据结构对应的 Java 类。例如,对于上述用户信息的 JSON 对象,可以定义一个 User 类,包含 name、age 和 Address 类(用于表示地址)等属性。然后可以通过 Gson 的 fromJson 方法将 JSON 字符串转换为 User 对象。
Gson gson = new Gson();
String jsonObjectStr = "{\"name\":\"张三\",\"age\":20,\"address\":{\"city\":\"北京\",\"district\":\"朝阳区\"}}";
User user = gson.fromJson(jsonObjectStr, User.class);
如果要生成 JSON 数据,使用 JSONObject 和 JSONArray 的 put 方法可以构建 JSON 数据结构。例如,要创建一个包含用户信息的 JSON 对象:
JSONObject jsonObject = new JSONObject();
try {jsonObject.put("name", "王五");jsonObject.put("age", 25);JSONObject addressObject = new JSONObject();addressObject.put("city", "上海");addressObject.put("district", "浦东新区");jsonObject.put("address", addressObject);String jsonStr = jsonObject.toString();// 此时jsonStr就是生成的JSON字符串,可以用于发送给服务器等操作
} catch (JSONException e) {e.printStackTrace();
}
同样,使用 Gson 也可以方便地将 Java 对象转换为 JSON 字符串,通过 toJson 方法即可。例如,将一个 User 对象转换为 JSON 字符串:
User user = new User("赵六", 30, new Address("广州", "天河区"));
Gson gson = new Gson();
String jsonStr = gson.toJson(user);
HTTP 请求过程包括哪些部分(请求行、请求头、请求体)?
首先是请求行。请求行位于 HTTP 请求的第一行,它包含了请求方法、请求的 URL 以及 HTTP 协议版本。请求方法常见的有 GET、POST、PUT、DELETE 等。GET 方法主要用于从服务器获取资源,例如获取网页内容、查询数据等。它将请求参数附加在 URL 后面,以 “?” 开始,参数之间用 “&” 连接。POST 方法则主要用于向服务器提交数据,比如提交表单数据。PUT 方法用于更新服务器上的资源,DELETE 方法用于删除服务器上的资源。请求行中的 URL 明确了请求资源的位置,HTTP 协议版本表明了客户端所使用的协议标准,如 HTTP/1.1 或 HTTP/2。
接着是请求头。请求头包含了许多关于请求的附加信息。例如,“User - Agent” 头信息用于告诉服务器客户端的软件环境,包括浏览器类型、操作系统等。服务器可以根据这个信息来提供适配的内容。“Content - Type” 头用于指定请求体的媒体类型,像 “application/json” 表示请求体是 JSON 格式的数据,“text/html” 表示是 HTML 格式的数据。“Accept” 头用于告诉服务器客户端能够接受的响应内容类型。还有像 “Cookie” 头,如果客户端之前已经和服务器建立过会话并且存储了 Cookie,会通过这个头信息将 Cookie 发送给服务器,用于服务器识别客户端身份等操作。
最后是请求体。请求体并不是在所有请求中都存在。对于 GET 请求,一般没有请求体,因为它主要是获取资源。而对于 POST、PUT 等请求,请求体就非常重要。它包含了要发送给服务器的数据。如果是 POST 请求提交表单数据,请求体可以包含表单中的各个字段及其值。如果是发送 JSON 数据,请求体就是符合 JSON 格式的字符串。在处理请求体时,服务器会根据请求头中的 “Content - Type” 来解析请求体中的数据。
如何理解 HTTP 的无状态特性?
HTTP 的无状态特性是指协议对于事务处理没有记忆能力。具体来说,当客户端向服务器发送一个请求,服务器根据请求进行响应后,服务器不会记住这个请求相关的任何信息。
例如,一个用户通过浏览器访问一个网页,服务器处理这个请求并返回网页内容。如果用户再次发送一个请求,即使是访问同一个网站的其他页面,服务器也不会根据之前的请求来处理这个新请求,而是完全独立地处理它。就好像每次请求都是来自一个全新的客户端一样。
这种无状态特性在某些方面有它的优势。它使得服务器的设计和实现相对简单,因为不需要维护复杂的状态信息。服务器可以专注于处理单个请求,提高了处理效率和可扩展性。例如,在一个高并发的网站中,服务器可以快速地处理大量的请求,而不需要考虑每个请求之前的关联情况。
然而,在很多实际应用场景中,这种无状态性也会带来不便。比如在一个用户登录后的购物网站中,用户将商品加入购物车。如果 HTTP 是完全无状态的,那么每次用户操作购物车,服务器都无法知道之前购物车的状态。为了解决这个问题,通常会采用一些技术来弥补无状态的缺陷。常见的方法是使用 Cookie 和 Session。Cookie 是存储在客户端浏览器中的小数据块,服务器可以通过在响应中设置 Cookie,让客户端保存。在后续的请求中,客户端会将 Cookie 发送给服务器,这样服务器就能识别客户端之前的状态。Session 则是存储在服务器端的用户状态信息,通过在客户端和服务器之间传递一个唯一的 Session ID(通常通过 Cookie),服务器可以根据这个 ID 来找到对应的 Session 数据,从而实现状态的跟踪。
HTTP 工作原理是什么?
HTTP(超文本传输协议)是一种用于分布式、协作式和超媒体信息系统的应用层协议。它的工作主要涉及客户端和服务器之间的交互。
当客户端(如浏览器)需要获取一个资源时,首先会构建一个 HTTP 请求。这个请求包含了前面提到的请求行、请求头和可能存在的请求体。请求行确定了请求的方法(如 GET、POST 等)和请求的目标资源的 URL,以及协议版本。请求头包含了各种附加信息,如客户端的身份标识、接受的数据格式等。如果是 POST 等需要发送数据的请求,请求体包含了要发送的数据。
这个请求会通过网络传输协议(如 TCP/IP)发送到服务器。服务器在接收到请求后,会首先解析请求。根据请求行中的方法和 URL,服务器会定位到对应的资源或者处理逻辑。例如,如果是 GET 请求获取一个网页,服务器会找到对应的网页文件。如果是 POST 请求提交数据,服务器会执行相应的数据处理程序。
在处理请求的过程中,服务器会根据请求头中的信息来确定如何处理请求体的数据。比如,如果请求头中的 “Content - Type” 表明是 JSON 数据,服务器会使用相应的 JSON 解析工具来解析数据。
服务器处理完请求后,会构建一个 HTTP 响应。响应同样包含响应行、响应头和响应体。响应行包含协议版本、响应状态码和状态描述。状态码用于告知客户端请求的处理结果,如 200 表示成功,404 表示资源未找到,500 表示服务器内部错误等。响应头包含了关于响应的各种信息,如响应内容的类型(通过 “Content - Type” 头)、响应内容的长度等。响应体则包含了实际要返回给客户端的内容,如网页的 HTML 代码、图片数据或者 JSON 格式的数据等。
最后,服务器将响应通过网络发送回客户端。客户端接收到响应后,会根据响应头中的信息来解析响应体。例如,如果响应头中的 “Content - Type” 表明是 HTML 内容,浏览器会对 HTML 进行渲染并显示给用户。如果是 JSON 数据,客户端应用程序可能会对 JSON 进行解析并进行相应的业务处理。
三次握手和四次挥手的过程是怎样的?
三次握手过程:
在网络通信中,TCP 建立连接时采用三次握手。首先,客户端向服务器发送一个 SYN(同步序列号)包,这个包中会包含客户端随机生成的初始序列号,它向服务器表明客户端想要建立连接,此时客户端进入 SYN_SENT 状态。例如,客户端生成的初始序列号假设为 X,这个 SYN 包就是告诉服务器客户端有建立连接的意向以及自己这边初始的一个顺序标识了。
然后,服务器收到客户端的 SYN 包后,会回复一个 SYN + ACK 包。SYN 部分是服务器自己生成的初始序列号(假设为 Y),表明服务器这边也准备好建立连接了,同时 ACK 部分是对客户端 SYN 包的确认,确认号就是客户端的初始序列号加 1(也就是 X + 1),意味着服务器已经收到客户端发送的那个 SYN 包了,此时服务器进入 SYN_RCVD 状态。
最后,客户端收到服务器的 SYN + ACK 包后,会再向服务器发送一个 ACK 包,这个 ACK 包的确认号就是服务器的初始序列号加 1(也就是 Y + 1),表示客户端确认收到了服务器的响应,至此客户端和服务器都进入 ESTABLISHED 状态,连接正式建立,可以开始进行数据传输了。通过这样的三次交互,双方都确认了彼此有建立连接以及接收和发送数据的能力。
四次挥手过程:
当通信双方想要关闭 TCP 连接时,采用四次挥手。首先,主动关闭方(比如客户端)发送一个 FIN(结束标志)包,表明自己已经没有数据要发送了,主动发起关闭连接的请求,此时主动关闭方进入 FIN_WAIT_1 状态。
接着,被动关闭方(比如服务器)收到 FIN 包后,会回复一个 ACK 包,确认收到了主动关闭方的关闭请求,此时被动关闭方进入 CLOSE_WAIT 状态,而主动关闭方收到这个 ACK 包后进入 FIN_WAIT_2 状态。
然后,被动关闭方在处理完自己这边剩余的数据等相关事务后,也会发送一个 FIN 包给主动关闭方,表示自己也准备好关闭连接了,此时被动关闭方进入 LAST_ACK 状态。
最后,主动关闭方收到被动关闭方的 FIN 包后,回复一个 ACK 包进行确认,之后主动关闭方进入 TIME_WAIT 状态,经过一段时间(通常是 2 倍的最大报文段生存时间,即 2MSL)后,主动关闭方才会真正关闭连接,而被动关闭方收到 ACK 包后就直接关闭连接了。这样通过四次交互,确保了双方都能妥善处理完数据和关闭连接的相关事宜,避免数据丢失等问题。
HashMap 怎么创建,参数如何设置?
在 Java 中创建 HashMap 有多种方式。最常见的一种是直接使用默认构造函数来创建,例如HashMap<String, Integer> hashMap = new HashMap<>();
,这里创建了一个键为字符串类型,值为整数类型的 HashMap。这种方式创建的 HashMap 会采用默认的初始容量和加载因子等参数设置。
初始容量指的是 HashMap 在创建时内部哈希表数组的大小,默认初始容量是 16。如果我们能预估到要存储的数据量比较大,可以在创建时指定初始容量。例如,预计要存储 100 个键值对,就可以像这样创建HashMap<String, Integer> hashMap = new HashMap<>(100);
,不过需要注意的是,HashMap 内部实际的初始容量会是大于等于指定容量的最小的 2 的幂次方数,也就是这里虽然指定了 100,但实际初始容量可能会是 128 等符合 2 的幂次方规则的数值,这样做是为了方便哈希计算等操作。
加载因子也是一个重要参数,它表示 HashMap 在其容量达到多少比例时开始进行扩容操作,默认加载因子是 0.75。也就是说,当 HashMap 中元素的数量达到当前容量乘以 0.75 这个阈值时,就会触发扩容,扩容操作会创建一个更大的哈希表数组,并重新对已有的元素进行哈希和存储,这个过程会有一定的性能开销。如果对内存使用等方面有特殊要求,也可以在创建时指定加载因子,像HashMap<String, Integer> hashMap = new HashMap<>(100, 0.8);
,这里就将加载因子设置为了 0.8,表示当元素数量达到容量乘以 0.8 时进行扩容。
另外,还可以通过已有的 Map 来创建 HashMap,比如有一个已经存在的Map<String, Integer> otherMap
,可以通过构造函数HashMap<String, Integer> hashMap = new HashMap<>(otherMap);
来创建一个包含了 otherMap 中所有键值对的 HashMap,这样在需要复制或者合并已有 Map 数据时会比较方便。
顺序的数组,如何将一个数插入合适的位置?
对于一个顺序的数组(这里假设是升序排列的数组,降序情况类似),要插入一个数到合适位置,可以按照以下的思路和步骤来操作。
首先,需要确定插入的位置。从数组的开头开始遍历数组元素,将待插入的数和数组中的每个元素依次进行比较。如果数组是升序排列的,那么当找到第一个比待插入数大的元素时,就意味着找到了插入位置,这个位置就是该元素的前一个位置。例如,有一个升序数组[1, 3, 5, 7, 9]
,要插入数字 4,从第一个元素 1 开始比较,1 小于 4,继续比较 3,3 也小于 4,当比较到 5 时,5 大于 4,那么 4 就应该插入到 3 后面、5 前面的这个位置。
在找到插入位置后,就需要为插入这个数腾出空间。因为数组的长度是固定的(在 Java 中普通数组一旦创建,长度不可变,不过可以通过一些手段来模拟可变长度的效果),所以要将插入位置及后面的元素依次向后移动一位。还是以上面的例子来说,要插入 4,就需要将 5、7、9 这几个元素依次向后移动一位,形成[1, 3, 空, 5, 7, 9]
这样的状态,这里的 “空” 就是腾出的位置。
然后,将待插入的数放入腾出的这个位置中,对于上述例子,就把 4 放入这个位置,数组最终就变成了[1, 3, 4, 5, 7, 9]
,这样就完成了将数插入到合适位置的操作。
在代码实现方面,可以使用循环来进行比较和元素的移动操作。以下是一个简单的示例思路(假设数组为int[] array
,待插入数为int num
):
先定义一个变量来记录插入位置,初始化为 0。然后通过循环遍历数组,比如for (int i = 0; i < array.length; i++)
,在循环中比较num
和array[i]
的大小,如果num
小于array[i]
,就说明找到了插入位置(也就是当前的i
),可以跳出循环。如果循环结束都没有找到比num
大的元素,那就意味着num
是最大的,插入位置就是数组的末尾(也就是array.length
这个位置)。
找到插入位置后,从数组末尾开始,使用一个循环将插入位置及后面的元素依次向后移动一位,比如for (int j = array.length - 1; j >= insertIndex; j--)
,在这个循环中进行array[j + 1] = array[j];
这样的操作来移动元素,最后再把num
放入插入位置array[insertIndex] = num;
。
同时,还需要考虑一些特殊情况,比如数组为空数组时,直接将数放入数组中就可以了,因为此时不存在比较和移动元素的情况;还有如果待插入的数和数组中的某个元素相等,根据具体的需求来决定是插入在相等元素的前面还是后面等细节问题。
二分查找的时间复杂度是多少?
二分查找是一种高效的查找算法,它应用于有序数组中,其时间复杂度是 O (log₂n),这里的 n 是数组中元素的个数。
下面来详细分析一下为什么是这样的时间复杂度。二分查找每次查找都会将查找区间缩小一半。比如一开始查找区间是整个数组,长度为 n,第一次比较中间元素后,就可以根据要查找的目标值和中间元素的大小关系,将查找区间缩小为原来的一半,要么是左半区间,要么是右半区间,此时查找区间长度变为 n/2。
接着第二次查找,又会把新的查找区间再缩小一半,变为 n/4,以此类推,不断重复这个过程,直到找到目标元素或者确定目标元素不存在为止。
假设经过 k 次查找后找到了目标元素或者确定不存在,那么查找区间的长度最后会变为 1,也就是 n / 2^k = 1,可以推导出 2^k = n,进而得出 k = log₂n。所以,二分查找最多需要比较的次数和数组元素个数 n 的对数成正比,时间复杂度就是 O (log₂n)。
在实际应用中,和一些简单的顺序查找算法(时间复杂度为 O (n))相比,当数组元素个数 n 比较大时,二分查找的优势就非常明显了。例如,有一个包含 1000 个元素的有序数组,顺序查找平均可能需要比较 500 次左右才能找到目标元素(假设目标元素等概率分布在数组中),而二分查找最多只需要比较大约 10 次左右(log₂1000 约等于 10),随着数组规模的进一步增大,这种效率上的差异会更加显著,这也体现了二分查找在处理有序数据查找时高效快速的特点。
斐波那契数列如何实现?
斐波那契数列是一个经典的数列,它的特点是从第三项开始,每一项都等于前两项之和,最开始的两项通常定义为 0 和 1(当然也可以根据具体需求从 1 和 1 等其他初始值开始)。以下是几种常见的实现斐波那契数列的方式。
递归实现方式:
递归的思路比较直观,斐波那契数列的定义本身就带有递归的性质。定义一个方法来计算斐波那契数列的第 n 项,例如:
public int fibonacci(int n) {if (n == 0) {return 0;}if (n == 1) {return 1;}return fibonacci(n - 1) + fibonacci(n - 2);
}
在这个方法中,当 n 为 0 时返回 0,n 为 1 时返回 1,对于其他大于 1 的 n,就通过递归调用自身,分别计算第 n - 1 项和第 n - 2 项的值,然后将它们相加得到第 n 项的值。不过这种递归实现方式虽然代码简洁直观,但存在效率问题,因为在计算过程中会有大量重复的计算。比如计算 fibonacci (5),会先计算 fibonacci (4) 和 fibonacci (3),而计算 fibonacci (4) 时又会再次计算 fibonacci (3) 等,随着 n 的增大,重复计算的次数会急剧增加,导致时间复杂度呈指数级增长,达到 O (2^n),在实际应用中对于较大的 n 性能较差。
迭代实现方式:
为了避免递归中的重复计算问题,可以采用迭代的方式,通过循环来依次计算斐波那契数列的每一项。可以使用两个变量来记录前两项的值,然后不断更新这两个变量来得到后续的项,示例如下:
public int fibonacciIterative(int n) {if (n == 0) {return 0;}if (n == 1) {return 1;}int first = 0;int second = 1;int result = 0;for (int i = 2; i <= n; i++) {result = first + second;first = second;second = result;}return result;
}
在这个代码中,首先处理了 n 为 0 和 1 的边界情况,然后对于 n 大于 1 的情况,通过循环从第 2 项开始计算,每次循环中先计算当前项(即前两项之和),然后更新前两项的值,让它们向后移动一位,经过 n - 1 次循环后,就得到了第 n 项的值。这种迭代实现方式的时间复杂度是 O (n),相比于递归实现,在效率上有很大的提升,尤其是当需要计算较大的 n 对应的斐波那契数列项时,性能表现更好。
动态规划实现方式:
动态规划也是一种有效的实现斐波那契数列的方法,它和迭代方式有相似之处,但更强调状态的定义和状态转移方程。可以定义一个数组来存储已经计算过的斐波那契数列的项,比如int[] dp = new int[n + 1];
,这里的 n 是要计算的斐波那契数列的项数。
初始化dp[0] = 0; dp[1] = 1;
,然后通过状态转移方程dp[i] = dp[i - 1] + dp[i - 2];
(这里的 i 从 2 开始)来依次计算后续的项,示例代码如下:
public int fibonacciDP(int n) {if (n == 0) {return 0;}if (n == 1) {return 1;}int[] dp = new int[n + 1];dp[0] = 0;dp[1] = 1;for (int i = 2; i <= n; i++) {dp[i] = dp[i - 1] + dp[i - 2];}return dp[n];
}
动态规划实现方式同样避免了重复计算,时间复杂度也是 O (n),并且通过数组存储中间结果,在一些需要多次查询不同项的斐波那契数列的场景下,还可以方便地获取已经计算过的结果,具有较好的灵活性和效率。
总之,不同的实现方式适用于不同的场景,递归方式代码简洁但效率低,迭代和动态规划方式在效率上更优,更适合在实际应用中需要高效计算斐波那契数列的情况。
ArrayList 和 LinkedList 有什么区别?
ArrayList 是基于动态数组实现的数据结构。它的存储方式是在内存中开辟一块连续的空间来存储元素。因为是数组结构,所以在访问元素时效率非常高。通过索引访问元素,时间复杂度为 O (1)。例如,当我们知道要访问的元素的索引为 3,就可以直接定位到数组中的这个位置获取元素,就像从一排连续摆放的盒子中,直接找到第三个盒子一样迅速。
在添加和删除元素方面,如果是在末尾添加元素,时间复杂度也是 O (1),因为它只需要在数组末尾添加一个元素即可。但是,如果是在中间或者开头插入元素,就需要移动插入位置后面的所有元素来腾出空间,此时时间复杂度为 O (n),n 是数组中元素的数量。比如在一个有 10 个元素的 ArrayList 中间插入一个元素,需要将后面的元素依次向后移动一位,这会随着元素数量的增加而变得耗时。
LinkedList 是基于链表实现的数据结构。它的每个节点包含数据和指向下一个节点(在双向链表中还包含指向前一个节点)的引用。在 LinkedList 中插入和删除元素相对高效,只要修改节点之间的引用关系即可。在链表中间插入一个元素,时间复杂度为 O (1)。例如,要在链表的某个节点后插入一个新节点,只需要调整前后节点的引用指向新节点就可以了。
然而,LinkedList 在访问元素时效率较低。因为要访问一个元素,需要从链表头(或者尾,取决于遍历方向)开始逐个节点遍历,时间复杂度为 O (n)。就好像在一个链条上找一个特定的环,需要一个一个地查看。
从数据结构角度看,ArrayList 更适合随机访问的场景,比如需要频繁根据索引获取元素。而 LinkedList 更适合频繁进行插入和删除操作的场景,特别是在不确定位置的插入和删除操作较多的情况下。
ArrayList 和 LinkedList 谁占内存更高?
在内存占用方面,ArrayList 和 LinkedList 有不同的特点。
ArrayList 由于是基于数组实现的,它在内存中占用的空间相对比较规整。它主要的内存开销来自于存储元素本身的空间以及一些少量的用于记录数组长度等信息的额外空间。当创建一个 ArrayList 时,即使没有添加元素,也会分配一定的初始容量的内存空间,这个初始容量在 Java 中默认是 10。随着元素的添加,当元素数量超过当前容量时,会进行扩容操作,通常是将容量扩大为原来的 1.5 倍左右,这会涉及到重新分配内存和复制元素的操作。
对于基本数据类型,例如ArrayList<int>
,每个元素占用的内存就是该基本数据类型本身的大小。对于引用类型,例如ArrayList<String>
,除了存储对象引用所占用的空间外,对象本身还占用额外的内存空间,这些对象存储在堆内存的其他位置。
LinkedList 的内存结构相对复杂一些。它的每个节点除了存储元素本身的数据外,还需要存储指向下一个节点(在双向链表中还有指向前一个节点)的引用。对于一个节点,这些引用也会占用一定的内存空间。例如在 Java 中,一个对象引用通常占用 4 个字节或者 8 个字节(取决于系统是 32 位还是 64 位)。
如果只存储少量元素,ArrayList 可能因为其初始的一些额外开销(如记录长度等信息)而在内存占用上稍高。但当元素数量增多,并且频繁进行插入和删除操作导致 LinkedList 中节点的引用开销累积起来,LinkedList 的内存占用可能会超过 ArrayList。特别是在存储基本数据类型且主要操作是随机访问的情况下,ArrayList 的内存利用效率通常更高。然而,如果存储的是大型的对象引用,并且插入和删除操作频繁,两者的内存占用情况会因具体的对象大小、操作频率等因素而变得比较复杂,需要具体分析。
从 ArrayList 和 LinkedList 中 get 一个数据,内存占用情况是怎样的?
对于 ArrayList,当执行 get 操作获取一个数据时,内存占用主要涉及到读取数组元素本身的内存空间。因为 ArrayList 是基于数组的,通过索引直接访问元素,在内存中数组元素是连续存储的。例如,在一个存储整数的 ArrayList 中,每个整数占用 4 个字节(假设是 32 位系统),当获取索引为 3 的元素时,只是简单地定位到数组中偏移量为 3 的位置读取这个 4 字节的数据,几乎没有额外的内存开销用于获取这个元素。
如果 ArrayList 存储的是对象引用,那么获取元素时除了读取引用本身的空间(在 32 位系统中通常是 4 字节,64 位系统中通常是 8 字节),还可能涉及到对象本身的内存占用情况。不过,在获取元素这个操作本身,只是读取引用,不会直接导致对象内存占用的变化。只有当对获取到的对象进行操作,比如访问对象的成员变量或者调用对象的方法时,可能会触发进一步的内存访问,比如加载对象的其他成员变量到内存中。
对于 LinkedList,情况就有所不同。当执行 get 操作时,需要从链表的头节点(或者尾节点,取决于遍历方向)开始逐个节点遍历,直到找到目标元素。这个遍历过程中,每访问一个节点,都需要读取节点的数据部分和节点的引用部分(指向下一个节点的引用,在双向链表中还包括指向前一个节点的引用)。
假设一个节点存储一个整数元素和两个引用(在双向链表的情况下),每个整数占用 4 字节,每个引用占用 4 字节(32 位系统),那么每个节点总共占用 12 字节。每次遍历节点时,这 12 字节的内存空间都会被访问。如果要获取的元素在链表的中间位置,那么可能需要遍历多个节点,这就会导致比 ArrayList 更多的内存访问操作。而且,由于节点在内存中的存储位置不是连续的,还可能涉及到更多的缓存未命中情况,从而导致从内存读取数据的效率降低,进一步影响性能和内存相关的开销。
所以,从内存占用角度看,ArrayList 在执行 get 操作时通常比 LinkedList 更高效,尤其是在频繁进行随机 get 操作的场景下,ArrayList 的内存占用和访问效率优势更明显。
ListView 怎么实现 UI 复用?
ListView 是 Android 中用于展示大量数据的一个重要视图组件。它实现 UI 复用主要是通过 ViewHolder 模式和回收机制。
首先,ViewHolder 模式是关键。ViewHolder 是一个内部类,用于缓存每个列表项中的视图。当 ListView 首次创建或者滚动时,会为每个可见的列表项创建一个 ViewHolder 对象。例如,一个简单的列表项可能包含一个 TextView 和一个 ImageView,那么 ViewHolder 类可以定义如下:
class ViewHolder {TextView textView;ImageView imageView;
}
在 ListView 的适配器(Adapter)的getView
方法中,会首先检查是否可以复用已经存在的视图。当一个列表项滚动出屏幕时,它对应的视图会被放入一个回收池中。当需要显示新的列表项时,会先从回收池中查找是否有可用的视图。如果有,就可以复用这个视图,避免了重新创建视图的开销。
在getView
方法中,可以这样使用 ViewHolder 来实现复用:
@Override
public View getView(int position, View convertView, ViewGroup parent) {ViewHolder holder;if (convertView == null) {convertView = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_layout, parent, false);holder = new ViewHolder();holder.textView = convertView.findViewById(R.id.text_view);holder.imageView = convertView.findViewById(R.id.image_view);convertView.setTag(holder);} else {holder = (ViewHolder) convertView.getTag();}// 根据position设置数据到holder中的视图return convertView;
}
当convertView
为 null 时,表示没有可复用的视图,需要创建新的视图并初始化 ViewHolder。当convertView
不为 null 时,就可以从convertView
的tag
中获取之前创建的 ViewHolder,然后直接使用这个 ViewHolder 中的视图来设置数据,而不需要重新创建视图。
这种回收和复用机制大大提高了 ListView 的性能。因为创建视图是一个比较耗时的操作,尤其是当列表项包含复杂的布局和视图时。通过复用视图,ListView 可以在滚动过程中快速地更新列表项的内容,减少了内存的占用和视图创建的开销,从而能够流畅地展示大量的数据。
Fragment 和 Activity 之间如何通讯?
Fragment 和 Activity 之间可以通过多种方式进行通讯。
一种常见的方式是通过接口回调。首先,在 Fragment 中定义一个接口,例如:
public interface OnFragmentInteractionListener {void onDataPass(String data);
}
然后,在 Fragment 的宿主 Activity 中实现这个接口。在 Fragment 中,通过onAttach
方法获取宿主 Activity 的实例并将其转换为接口类型,例如:
private OnFragmentInteractionListener mListener;@Override
public void onAttach(Context context) {super.onAttach(context);if (context instanceof OnFragmentInteractionListener) {mListener = (OnFragmentInteractionListener) context;} else {throw new RuntimeException(context.toString() + " must implement OnFragmentInteractionListener");}
}
当 Fragment 中有数据需要传递给 Activity 或者需要调用 Activity 中的方法时,就可以通过这个接口来实现。例如,当 Fragment 中有一个按钮点击事件,需要将一些数据传递给 Activity,可以在按钮点击方法中调用接口方法:
mListener.onDataPass("传递的数据");
在 Activity 中实现接口方法,就可以接收来自 Fragment 的数据或者执行相应的操作。
另一种方式是通过 Bundle 传递数据。当创建 Fragment 时,可以将数据放在 Bundle 中传递给 Fragment。例如,在 Activity 中:
MyFragment fragment = new MyFragment();
Bundle bundle = new Bundle();
bundle.putString("key", "传递的数据");
fragment.setArguments(bundle);
在 Fragment 中,可以在onCreate
方法或者其他生命周期方法中获取 Bundle 中的数据,例如:
@Override
public void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);Bundle args = getArguments();if (args!= null) {String data = args.getString("key");// 处理数据}
}
还可以通过共享 ViewModel 来实现通讯。如果使用了 Android Architecture Components 中的 ViewModel,Fragment 和 Activity 可以共享一个 ViewModel。首先,在 Activity 中创建 ViewModel:
MyViewModel viewModel = ViewModelProviders.of(this).get(MyViewModel.class);
然后,在 Fragment 中获取相同的 ViewModel:
MyViewModel viewModel = ViewModelProviders.of(getActivity()).get(MyViewModel.class);
这样,Fragment 和 Activity 就可以通过这个共享的 ViewModel 来共享数据和状态。例如,ViewModel 中有一个 LiveData 对象,Fragment 和 Activity 都可以观察这个 LiveData 的变化,当数据发生变化时,两者都可以收到通知并进行相应的操作。
Activity 的启动模式有哪些?
Activity 有四种启动模式,分别是 standard、singleTop、singleTask 和 singleInstance。
standard 是默认的启动模式。在这种模式下,每当启动一个新的 Activity 实例,就会创建一个新的 Activity 对象并放入任务栈中。例如,一个应用中有 Activity A,从 A 中启动 A 自身多次,就会创建多个 A 的实例并依次堆叠在任务栈里。这种模式简单直接,适合大多数普通场景,不过如果不注意控制,可能会导致创建大量的 Activity 实例,占用较多内存。
singleTop 模式下,如果要启动的 Activity 已经处于任务栈的栈顶,就不会创建新的实例,而是直接复用栈顶的那个 Activity 实例,并且会调用它的 onNewIntent 方法来传递新的意图。比如有 Activity B,当 B 在栈顶,再次启动 B 时,就不会新创建 B,而是在已有的 B 实例上处理新的启动请求。这种模式适用于一些接收频繁更新的通知等场景,能够避免重复创建相同的 Activity。
singleTask 模式比较特殊。在这种模式下,当启动一个 Activity 时,系统会在任务栈中检查是否已经存在该 Activity 的实例。如果存在,就会把这个 Activity 之上的所有其他 Activity 都清除出栈,然后把这个存在的 Activity 实例放到栈顶,让它获得焦点并调用 onNewIntent 方法。例如,有任务栈中有 A - B - C,C 是 singleTask 模式的 Activity,当启动 C 时,会把 B 和 A 从栈中清除,C 位于栈顶。这种模式常用于应用的主界面或者具有全局唯一性的功能界面,保证在整个应用的任务栈中该 Activity 只有一个实例。
singleInstance 模式下,该 Activity 会单独位于一个新的任务栈中。这个任务栈只有这一个 Activity 实例,其他应用可以通过特定的方式访问这个 Activity。当启动这个 Activity 时,如果它不存在,就会创建一个新的任务栈并放入该 Activity;如果已经存在,就直接使用已有的任务栈和 Activity 实例。这种模式适用于一些需要和其他应用共享的独立界面,比如系统的拨打电话界面等,保证该 Activity 的独立性和唯一性。
游戏开发中会设置什么启动模式?
在游戏开发中,启动模式的选择取决于游戏的具体设计和功能需求。
如果游戏有一个主要的游戏场景界面,例如一个角色扮演游戏的主游戏世界界面,这个界面可以考虑使用 singleTask 启动模式。因为在游戏运行过程中,玩家大部分时间会在这个主界面进行游戏操作,设置为 singleTask 可以保证这个主界面在任务栈中只有一个实例。当玩家从其他辅助界面(如游戏商店、任务说明界面等)返回主界面时,能够快速地恢复到之前的游戏状态,并且清除掉不需要的中间界面,避免任务栈过度复杂。
对于一些频繁切换但又不需要重复创建的界面,比如游戏中的提示信息界面,singleTop 模式比较合适。这些提示信息可能会在游戏过程中多次出现,例如获得新技能提示、任务完成提示等。当提示信息界面已经在栈顶时,再次打开它可以直接复用现有实例,更新提示内容,这样能够提高性能,减少不必要的界面创建和销毁操作。
另外,如果游戏中有一些与外部应用交互的独立界面,例如游戏内的分享界面或者调用系统功能(如拍照用于游戏头像)的界面,singleInstance 模式会是一个好的选择。这样可以确保这些界面在独立的任务栈中,不影响游戏本身的任务栈结构,并且能够方便地与外部应用进行交互。同时,游戏的主要游戏流程相关的 Activity 可以保持在自己的任务栈中,使得游戏的任务栈管理更加清晰,便于开发和维护。
在一些简单的小游戏中,如果界面比较单一,没有复杂的界面切换和多任务需求,也可以使用默认的 standard 启动模式。不过,随着游戏功能的增加和界面的复杂程度提高,为了更好地控制 Activity 的生命周期和任务栈,通常需要根据具体情况合理选择其他启动模式。
类初始化过程(包括子类、父类)以及为什么静态先初始化?
类的初始化过程涉及到多个阶段,包括加载、验证、准备、解析和初始化。
首先是加载阶段,这个阶段主要是将类的二进制字节流加载到内存中,生成一个代表这个类的 Class 对象。这个过程是由类加载器(ClassLoader)来完成的。例如,当程序中首次使用一个类,如通过new
关键字创建该类的实例或者访问该类的静态成员时,类加载器就会开始加载这个类。
验证阶段主要是确保被加载的类的字节码符合 Java 虚拟机的规范,包括格式检查、语义检查等,以保证类的正确性和安全性。
准备阶段是为类的静态变量分配内存空间并设置默认初始值。例如,对于static int a;
,在这个阶段会为a
分配内存空间,并且将其初始值设为 0。
解析阶段是将类中的符号引用转换为直接引用,这样在后续使用这些引用时能够直接定位到内存中的实际对象或者方法。
初始化阶段是真正执行类的初始化代码。对于类的静态变量的初始化和静态代码块的执行就在这个阶段进行。
在涉及子类和父类时,父类会先于子类进行初始化。这是因为子类继承了父类的成员和方法,在子类初始化之前,需要保证父类已经初始化完成,这样子类才能正确地继承和使用父类的资源。
静态成员先初始化是因为静态成员是属于类的,而不是属于类的某个具体实例。它们在类加载后就可以被使用,并且在整个类的生命周期内只初始化一次。例如,一个类中有一个静态方法,在没有创建类的实例时就可以通过类名来调用这个静态方法,所以静态成员需要先完成初始化,以保证在类被使用时这些静态资源是可用的。而且,静态变量的初始化和静态代码块的执行顺序是按照它们在类中的定义顺序来进行的。
类实例化过程:父类的静态代码块、非静态代码块和子类的静态代码块、非静态代码块的初始化顺序是怎样的?怎么理解这个顺序?
在类实例化过程中,初始化顺序是先执行父类的静态代码块,接着执行子类的静态代码块,然后执行父类的非静态代码块,最后执行子类的非静态代码块。
首先,父类的静态代码块会先执行。这是因为静态代码块是在类加载阶段进行初始化的,并且父类的加载优先于子类。静态代码块用于初始化类的静态资源,这些资源在类的整个生命周期内是共享的。例如,在一个类中有一个静态变量用于记录类的实例创建次数,那么这个静态变量的初始化就可以放在静态代码块中,而且这个初始化过程会在类的第一次使用时(包括实例化)就完成。
然后是子类的静态代码块。子类在加载时,也需要进行自己的静态资源初始化。当子类加载完成后,就开始实例化过程。
接着执行父类的非静态代码块。非静态代码块在每次创建类的实例时都会执行,它主要用于初始化实例相关的资源。在创建子类实例时,由于子类继承了父类的属性和方法,所以要先初始化父类的实例相关资源,确保父类的状态是正确的,这样子类才能在正确的基础上进行初始化。
最后执行子类的非静态代码块。此时子类已经完成了对父类的继承和父类实例资源的初始化,通过执行子类的非静态代码块来初始化子类特有的实例资源,完成整个实例化过程。
这种顺序的设计是为了保证类的层次结构的完整性和正确性。从类的加载角度看,静态代码块先于实例化过程执行,并且父类优先于子类加载。从实例化角度看,先初始化父类的实例资源,再初始化子类的实例资源,符合继承关系的逻辑,使得子类在继承父类的基础上能够正确地构建自己的状态,避免出现因初始化顺序错误导致的资源未初始化或者错误初始化等问题。
字符串的 == 和 equals 方法有什么区别?
在 Java 中,字符串的==
和equals
方法有明显的区别。
==
是一个比较运算符,用于比较两个对象的引用是否相等。当使用==
比较两个字符串时,它实际上是在比较这两个字符串对象在内存中的地址是否相同。例如,有两个字符串变量str1
和str2
,如果str1 == str2
,这意味着str1
和str2
指向的是内存中的同一个字符串对象。
如果通过直接赋值的方式创建字符串,例如String str1 = "abc";
和String str2 = "abc";
,在 Java 中,编译器会把相同的字符串字面量存储在一个字符串常量池中。当创建str2
时,发现字符串常量池中已经有了abc
这个字符串,就会直接让str2
指向这个已经存在的字符串对象,所以此时str1 == str2
为真。
但是,如果通过new
关键字来创建字符串,例如String str3 = new String("abc");
,这会在堆内存中创建一个新的字符串对象,即使字符串内容和前面的str1
相同,str1 == str3
也为假,因为它们指向的是不同位置的对象。
equals
方法是String
类从Object
类中继承并重写后的方法,用于比较两个字符串的内容是否相等。对于String
类,equals
方法会逐个字符地比较两个字符串的内容。例如,对于前面提到的str1
和str3
,虽然str1 == str3
为假,但是str1.equals(str3)
为真,因为它们的内容都是abc
。
在实际编程中,如果想要比较两个字符串是否在内容上相同,应该使用equals
方法,而==
运算符主要用于比较对象的引用是否相同。如果在比较字符串时错误地使用了==
,可能会导致比较结果不符合预期,特别是在涉及到字符串的拼接、从不同方式获取字符串等情况下,更应该注意正确使用比较方法。
Java 中的静态(static)、final 关键字怎么理解?
静态(static)关键字:
静态关键字在 Java 中有很重要的作用。从成员变量角度来看,当一个变量被声明为静态变量时,它属于类本身,而不是类的某个具体实例。也就是说,无论创建多少个该类的对象,静态变量在内存中只有一份副本,被所有实例所共享。例如,定义一个类Student
,里面有static int count;
,这个count
变量可以用来记录创建的Student
对象的数量,每当创建一个新的Student
对象时,都可以对count
进行操作(如count++
),所有Student
对象看到的count
值都是一样的,它存储在方法区中。
对于静态方法,同样也是属于类的,无需创建类的实例就可以直接通过类名来调用。像Math
类中的很多方法(如Math.sqrt()
等)都是静态方法,方便在程序的各个地方直接调用进行数学运算等操作。静态方法不能直接访问非静态的成员变量和非静态的方法,因为非静态成员是和具体实例相关联的,在没有实例的情况下它们不存在,而静态方法调用时可能并没有对应的实例被创建。
另外,静态代码块也是类加载时就会执行的代码块,主要用于初始化类的静态资源,按照在类中定义的顺序执行一次,通常用来做一些类级别的初始化工作,比如初始化静态变量的复杂赋值等操作。
final 关键字:
当 final 用于修饰变量时,意味着这个变量一旦被赋值,就不能再被修改,它变成了一个常量。如果是基本数据类型的 final 变量,其值不可变,例如final int num = 10;
,后续就不能再给num
重新赋值了。若是引用类型的 final 变量,虽然变量本身的引用不能改变,也就是不能再指向其他对象了,但对象内部的属性是可以修改的(如果对象的属性没有被声明为 final 等不可变的情况),比如final ArrayList<String> list = new ArrayList<>();
,不能再让list
指向其他的ArrayList
对象了,但可以对list
进行添加、删除元素等操作。
final 修饰类时,表示这个类不能被继承。像String
类就是被 final 修饰的,这确保了它的结构和功能的完整性与唯一性,避免其他类对其进行不恰当的扩展而破坏其原本的特性。
当 final 修饰方法时,该方法不能被子类重写。这样可以保证在多态情况下,某个方法的具体实现逻辑在父类中定义好后,子类不能随意更改,维持了方法行为的确定性。