- Unity3D高級編程:主程手記
- 陸澤西
- 2705字
- 2022-01-07 14:46:17
2.5.2 裝箱和拆箱
什么是裝箱和拆箱?其實很簡單,把值類型實例轉換為引用類型實例,就是裝箱。反之,把引用類型實例轉換為值類型實例,就是拆箱。
針對這個解釋,可能有讀者還有疑問:什么是值類型?什么是引用類型?值類型的變量會直接存儲數據,如byte、short、int、long、float、double、decimal、char、bool和struct,統稱為值類型;而引用類型的變量持有的是數據的引用,其真實數據存儲在數據堆中,如所有的class實例的變量、string和class,統稱為引用類型。當聲明一個類時,只在堆棧(堆或棧)中分配一小片內存用于容納一個地址,而此時并沒有為其分配堆上的內存空間,因此它是空的,為null,直到使用new創建一個類的實例,分配了一個堆上的空間,并把堆上空間的地址保存給這個引用變量,這時這個引用變量才真正指向內存空間。
我們解釋得再通俗點,舉個例子來說明:
int a = 5; object obj = a;
這就是裝箱,因為a是值類型,是直接有數據的變量,obj為引用類型,指針與內存拆分開來,把a賦值給obj,實際上就是obj為自己創建了一個指針,并指向了a的數據空間。繼續上面的代碼:
a = (int)obj;
這就是拆箱,相當于把obj指向的內存空間復制一份交給了a,因為a是值類型,所以它不允許指向某個內存空間,只能靠復制數據來傳遞數據。
為何需要裝箱?
值類型是在聲明時就初始化了的,因為它一旦聲明,就有了自己的空間,因此它不可能為null,也不能為null。而引用類型在分配內存后,它其實只是一個空殼子,可以認為是指針,初始化后不指向任何空間,因此默認為null。
值類型包括所有整數、浮點數、bool和Struct聲明的結構。這里要注意Struct部分,這是我們經常犯錯誤的地方,很多人會把它當作類來使用,這是錯誤的行為。因為它是值類型,在復制操作時是通過直接復制數據完成操作的,所以常常會有a、b同是結構的實例,a賦值給了b,在b更改了數據后,發現a的數據卻沒有同步的疑問出現,事實上,它們根本就是兩個數據空間,在a賦值給b時,其實并不是引用復制,而是整個數據空間復制,相當于a、b為兩個不同的西瓜,只是長得差不多而已。
引用類型包括類、接口、委托(委托也是類)、數組以及內置的object與string。前面說了,delegate也是類,類都是引用類型,雖然有點問題,也不妨礙它是一個比較好記的口號。雖然int等值類型也都是類,但它們是特殊的類,是值類型的類,因為在C#里萬物皆是類。
這里稍微闡述一下堆內存和棧內存,因為很多人對堆棧內存有錯誤認知。
棧是用來存放對象的一種特殊的容器,它是最基本的數據結構之一,遵循先進后出的原則。它是一段連續的內存,所以對棧數據的定位比較快速;而堆則是隨機分配的空間,處理的數據比較多,無論情況如何,都至少要兩次才能定位。堆內存的創建和刪除節點的時間復雜度是O(lgn)。棧創建和刪除的時間復雜度則是O(1),棧速度更快。
既然棧速度這么快,全部用棧不就好了?這又涉及生命周期問題,由于棧中的生命周期必須確定,銷毀時必須按次序銷毀,即從最后分配的塊部分開始銷毀,創建后什么時候銷毀必須是一個定量,所以在分配和銷毀上不靈活,它基本都用于函數調用和遞歸調用這些生命周期比較確定的地方。相反,堆內存可以存放生命周期不確定的內存塊,滿足當需要刪除時再刪除的需求,所以堆內存相對于全局類型的內存塊更適合,分配和銷毀更靈活。
很多人把值類型與引用類型歸為棧內存和堆內存分配的區別,這是錯誤的,棧內存主要為確定性生命周期的內存服務,堆內存則更多的是無序的隨時可以釋放的內存。因此值類型可以在堆內也可以在棧內,引用類型的指針部分也一樣,可以在棧內和堆內,區別在于引用類型指向的內存塊都在堆內,一般這些內存塊都在委托堆內,這樣便于內存回收和控制,我們平時所說的GC機制就會做些回收和整理的事。也有非委托堆內存不歸委托堆管理的部分,它們是需要自行管理的,比如C++編寫一個接口生成一個內存塊,將指針返回給了C#程序,這個非委托堆內存就需要我們自行管理,C#也可以自己生成非委托堆內存塊。
大部分時候,只有當程序、邏輯或接口需要更加通用的時候才需要裝箱。比如調用一個含類型為object的參數的方法,該object可支持任意類型,以便通用。當你需要將一個值類型(如Int32)傳入時,就需要裝箱。又比如一個非泛型的容器為了保證通用,而將元素類型定義為object,當值類型數據加入容器時,就需要裝箱。
下面我們來看看裝箱的內部操作。
根據相應的值類型在堆中分配一個值類型內存塊,再將數據復制給它,這要按三步進行。
第一步:在堆內存中新分配一個內存塊(大小為值類型實例大小加上一個方法表指針和一個SyncBlockIndex類)。
第二步:將值類型的實例字段復制到新分配的內存塊中。
第三步:返回內存堆中新分配對象的地址。這個地址就是一個指向對象的引用。
拆箱則更為簡單,先檢查對象實例,確保它是給定值類型的一個裝箱值,再將該值從實例復制到值類型變量的內存塊中。
裝箱、拆箱對執行效率有什么影響,如何優化?
由于裝箱、拆箱時生成的是全新的對象,不斷地分配和銷毀內存不但會大量消耗CPU,同時也會增加內存碎片,降低性能。那該如何做呢?
我們需要做的就是減少裝箱、拆箱的操作。在編程規范中要牢記減少這種浪費CPU內存的操作,在平時編程時要特別注意。
整數、浮點數、布爾等值類型變量的變化手段很少,主要靠加強規范、減少裝拆箱的情況來提高性能。Struct不一樣,它既是值類型,又可以像類一樣繼承,用途多,轉換的途徑也多,但稍不留神,花樣就變成了麻煩,所以這里講講Struct變化后的優化方法。
1)Struct通過重載函數來避免拆箱、裝箱。
比如常用的ToString()、GetType()方法,如果Struct沒有寫重載ToString()和GetType()的方法,就會在Struct實例調用它們時先裝箱再調用,導致內存塊重新分配,性能損耗,所以對于那些需要調用的引用方法,必須重載。
2)通過泛型來避免拆箱、裝箱。
不要忘了Struct也是可以繼承的,在不同的、相似的、父子關系的Struct之間可以使用泛型來傳遞參數,這樣就不用在裝箱后再傳遞了。
比如B、C繼承A,就有這個泛型方法void Test(T t) where T:A,以避免使用object引用類型形式來傳遞參數。
3)通過繼承統一的接口提前拆箱、裝箱,避免多次重復拆箱、裝箱。
很多時候拆箱、裝箱不可避免,這時可以讓多種Struct繼承某個統一的接口,不同的Struct可以有相同的接口。把Struct傳遞到其他方法里,就相當于提前進行了裝箱操作,在方法中得到的是引用類型的值,并且有它需要的接口,避免了在方法中完成重復多次的拆箱、裝箱操作。
比如Struct A和Struct B都繼承了接口I,我們調用的方法是void Test(I i)。當調用Test方法時,傳進去的Struct A或Struct B的實例相當于提前執行了裝箱操作,Test方法里拿到參數后就不用再擔心內部再次出現裝箱、拆箱的問題了。
最后依然要提醒大家,如果沒有理解Struct值類型數據結構的原理,用起來可能會存在很多麻煩,不要盲目認為使用結構體會讓性能提升,在沒有完全徹底理解之前就貿然大量使用結構體可能會對你的程序性能帶來重創。
- Learning LibGDX Game Development(Second Edition)
- Spring Boot開發與測試實戰
- Python科學計算(第2版)
- Computer Vision for the Web
- Hands-On Data Structures and Algorithms with JavaScript
- Swift 3 New Features
- Quarkus實踐指南:構建新一代的Kubernetes原生Java微服務
- GameMaker Programming By Example
- concrete5 Cookbook
- Unreal Engine 4 Shaders and Effects Cookbook
- AppInventor實踐教程:Android智能應用開發前傳
- Mastering ROS for Robotics Programming
- Internet of Things with ESP8266
- Spring+Spring MVC+MyBatis從零開始學
- ActionScript 3.0從入門到精通(視頻實戰版)